WordPress Logo WordPress お問い合わせフォームをプラグインなしで作成

更新日:2025年06月27日

作成日:2024年5月14日

WordPress のテーマでお問い合わせフォームを作成するサンプルコードと解説です。

ブロックテーマに対応したサンプルも追加しました。

関連ページ

作成するコンタクトフォームの概要

独自に作成したクラシックテーマの固定ページ(カスタムページテンプレート)を使って、お問い合わせページを作成します。

データベースに内容を保存する機能はなく、シンプルですがセキュリティに配慮された構成です。

メール送信には、PHPMailer をベースにした WordPress 独自の関数 wp_mail() を使用します。

入力値の検証は JavaScript(クライアントサイド)と PHP(サーバーサイド) の両方で行い、Google が提供する認証システム「reCAPTCHA v3」も実装しています。

また、デフォルトで自動返信メールを送信しますが、必要に応じてオフにできるようになっています。

セキュリティ対策として、CSRF(クロスサイトリクエストフォージェリ)やクリックジャッキング対策、入力バリデーション、セッションハイジャック防止などにも配慮した実装となっています。

使用するテンプレートファイル(全3ページ構成)

  • 入力ページ : contact.php
  • 確認ページ : confirm.php
  • 完了ページ : complete.php

その他の作成ファイル

  • helpers.php:入力値の検証・エスケープ処理などの共通関数を定義
  • contact-complete.js:完了ページでのブラウザ履歴対策
  • form-validation.js:JavaScriptによる入力値のバリデーション処理
  • recaptcha-v3-handler.js:reCAPTCHA v3 のトークン取得および送信処理

その他の設定ファイル・変更点

  • functions.php:ファイルの読み込み、phpmailer_init による送信設定、セッション管理の処理を追加
  • wp-config.php:メール送信先や reCAPTCHA キー、SMTP 設定などの定義を追加
  • .htaccess:直接アクセスを防止する記述を追加(Apache 環境の場合)

以下がファイル構成です。環境や必要に応じて変更します。

themes/
 ├─ example/  # テーマフォルダ
      ├─ complete.php   # 完了ページテンプレート
      ├─ confirm.php    # 確認ページテンプレート
      ├─ contact.php    # 入力ページテンプレート
      ├─ functions.php  # JS の読み込みや phpmailer_init の設定、セッション処理などを追加
      ├─ inc/
      │   └─ contact/
      │       └─ helpers.php # 共通関数(入力値検証やエスケープ処理、reCAPTCHA 検証などの関数群)
      ├─ index.php
      ├─ js/
      │   ├─ contact-complete.js  # ブラウザの履歴対策
      │   ├─ form-validation.js  # フォームのバリデーション処理
      │   └─ recaptcha-v3-handler.js # reCAPTCHA V3 のトークン処理
      ├─ ....
      └─ style.css

wp-config.php # メール送信先や reCAPTCHA キー、パスワードなどの情報を定数として定義
.htaccess # wp-config.php を保護する記述を追加

この例のテーマは、少し(かなり)古いですが Underscores を利用しています。

以下はこのテーマの header.php と footer.php(Underscores のコードそのまま)です。

header.php と footer.php を開く
<!doctype html>
<html <?php language_attributes(); ?>>
<head>
  <meta charset="<?php bloginfo( 'charset' ); ?>">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="profile" href="https://gmpg.org/xfn/11">

  <?php wp_head(); ?>
</head>

<body <?php body_class(); ?>>
<?php wp_body_open(); ?>
<div id="page" class="site">
  <a class="skip-link screen-reader-text" href="#primary"><?php esc_html_e( 'Skip to content', 'example' ); ?></a>

  <header id="masthead" class="site-header">
    <div class="site-branding">
    ・・・中略・・・
    </div><!-- .site-branding -->

    <nav id="site-navigation" class="main-navigation">
    ・・・中略・・・
    </nav><!-- #site-navigation -->
  </header><!-- #masthead -->
<footer id="colophon" class="site-footer">
    <div class="site-info">
    ・・・中略・・・
    </div><!-- .site-info -->
  </footer><!-- #colophon -->
</div><!-- #page -->

<?php wp_footer(); ?>

</body>
</html>

コンタクトフォームの作りとしては、基本的に以下のページに掲載されている内容と同じですが、WordPress 用に書き換えています。

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

wp-config.php

wp-config.php にメール送信先や reCAPTCHA キー、パスワードなどの情報を定数として定義します。メールアドレスやパスワードなどは実際の値に書き換えます。

/* Add any custom values between this line and the "stop editing" line. */

/* コンタクトフォーム関連の定数定義
------------------------------------------------------------ */

// メール送信に使用する情報
define('MAIL_TO', 'info@your-site.com');            // お問い合わせメールの送信先メールアドレス(受信者)
define('MAIL_TO_NAME', 'Your Site Name');           // お問い合わせメールの送信先の名前
define('MAIL_FROM', 'info@your-site.com');          // 送信元メールアドレス(Fromヘッダー)
define('MAIL_FROM_NAME', 'Your Site Name');         // 送信元の表示名(Fromヘッダー)
define('MAIL_RETURN_PATH', 'info@your-site.com');   // 送信エラー時のエラーメールを受け取るアドレス(Return-Path)

// 自動返信メールの設定
define('AUTO_REPLY_ENABLED', true);                 // 自動返信メールの送信を有効にするか(trueで送信する)
define('AUTO_REPLY_NAME', '自動返信 返信先名前');    // 自動返信メールの送信元名(自動返信を有効にした場合は必須)

// Cc / Bcc の設定(必要に応じて使用)
define('MAIL_CC', 'cc@your-site.com');              // Cc(カーボンコピー)を送信するメールアドレス(任意)
define('MAIL_CC_NAME', 'Cc の宛先名');              // Cc 宛の名前(任意)
define('MAIL_BCC', 'bcc@your-site.com');            // Bcc(ブラインドカーボンコピー)宛のメールアドレス(任意)

// reCAPTCHA v3 の設定
define('RECAPTCHA_V3_SITE_KEY', 'xxxxxxxxxxxxxxxxx');     // reCAPTCHA v3 のサイトキー(フロント側で使用)
define('RECAPTCHA_V3_SECRET_KEY', 'xxxxxxxxxxxxxxxxxx');  // reCAPTCHA v3 のシークレットキー(サーバー側で使用)

// 開発・デバッグ用設定
define('SHOW_RECAPTCHA_V3_RESULT', false);          // reCAPTCHA のスコア判定結果を完了ページに表示する(本番環境では false に設定)

// SMTP(PHPMailer)によるメール送信設定
define('SMTP_HOST', 'smtp.your-site.com');          // SMTP サーバーのホスト名
define('SMTP_PORT', 587);                           // SMTP ポート番号(通常は 587)
define('SMTP_AUTH', true);                          // SMTP 認証の使用有無(trueで認証を行う)
define('SMTP_SECURE', 'tls');                       // 通信の暗号化方式('tls' または 'ssl')
define('SMTP_USER', 'info@your-site.com');          // SMTP 認証に使用するユーザー名(通常はメールアドレス)
define('SMTP_PASS', 'xxxxxxxxxxxx');                // SMTP 認証に使用するパスワード

/* That's all, stop editing! Happy publishing */
  • MAIL_RETURN_PATH を指定しておくことで、送信失敗時にエラーメールを適切に受け取れます。
  • AUTO_REPLY_ENABLED を false にすると、自動返信は行われません。
  • SHOW_RECAPTCHA_V3_RESULT は開発中のみ true にして、スコアやエラーを画面に表示してデバッグする目的です。

wp-config.php の保護(.htaccess)

wp-config.php には、データベース接続情報やセキュリティキーなどの重要な機密情報が含まれています。また、この例の場合、SMTP の認証情報(ユーザー名・パスワードなど)や reCAPTCHA のシークレットキーなども定義しています。

そのため、wp-config.php への直接アクセスを防ぐことはセキュリティにおいて非常に重要です。

Apache 環境の場合(.htaccess が機能する場合)

Apache を使用している場合は、.htaccess ファイルを利用して wp-config.php へのアクセスを拒否できます。以下のような記述を .htaccess に追加することで、外部からの直接アクセスを禁止できます。

# wp-config.php ファイル直アクセスを禁止(Apache 2.4 以降の場合)
<Files "wp-config.php">
  Require all denied
</Files>

上記は Apache 2.4 以降の推奨構文です。Apache 2.2 以前を使用している場合は次のように記述します。

<Files "wp-config.php">
  order allow,deny
  deny from all
</files>

必要に応じて、以下のように IfModule を使って両バージョンに対応させることも可能です。

<Files "wp-config.php">
  <IfModule mod_authz_core.c>
    Require all denied
  </IfModule>
  <IfModule !mod_authz_core.c>
    Order allow,deny
    Deny from all
  </IfModule>
</Files>

記述位置の注意

.htaccess ファイルにこの設定を追加する際は、WordPress が自動的に生成・上書きする範囲(# BEGIN WordPress ~ # END WordPress)の外側に記述します。

例えば、以下のように記述するのが安全です。

# wp-config.php の保護
<Files "wp-config.php">
  Require all denied
</Files>

# BEGIN WordPress
# "BEGIN WordPress" から "END WordPress" までのディレクティブ (行) は
# ...
<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
  # ... 中略 ...
</IfModule>
# END WordPress

functions.php

JavaScriptファイルの読み込みや phpmailer_init アクションフックによる SMTP 送信設定、セッション管理などの処理を追加します。

// テーマで使用する JavaScript およびスタイルシートの読み込み処理
function my_enqueue_contact_script_style() {

  // 「お問い合わせページ(contact)」のときのみバリデーション用スクリプトを読み込む
  if (is_page('contact')) {
    wp_enqueue_script(
      'form-validation-js', // ハンドル名
      get_theme_file_uri('/js/form-validation.js'), // ファイルの URL
      array(), // 依存スクリプト(なし)
      filemtime(get_theme_file_path('/js/form-validation.js')), // キャッシュ対策:更新時刻をバージョンとして指定
      true // フッターに出力
    );
  }

  // 「確認ページ(confirm)」のときにのみ reCAPTCHA v3 用スクリプトを読み込む
  if (is_page('confirm')) {

    // wp-config.php に定義された reCAPTCHA サイトキーを取得(定義されていない場合は空文字)
    $site_key = defined('RECAPTCHA_V3_SITE_KEY') ? RECAPTCHA_V3_SITE_KEY : '';

    // Google の reCAPTCHA v3 API を読み込む(?render=サイトキー 付き)
    wp_enqueue_script(
      'google-recaptcha-v3',
      "https://www.google.com/recaptcha/api.js?render={$site_key}",
      array(),
      null, // バージョン(指定なし)
      true
    );

    // 自作の reCAPTCHA トークン処理用スクリプトを読み込む
    wp_enqueue_script(
      'recaptcha-v3-handler-js',
      get_theme_file_uri('/js/recaptcha-v3-handler.js'),
      array('google-recaptcha-v3'), // reCAPTCHA API に依存
      filemtime(get_theme_file_path('/js/recaptcha-v3-handler.js')),
      true
    );

    // JavaScript 内で reCAPTCHA サイトキーを参照できるようにグローバル変数として出力
    wp_add_inline_script(
      'recaptcha-v3-handler-js',   // データを渡す対象の JavaScript ファイルのハンドル名
      'window.recaptchaV3SiteKey = "' . esc_js($site_key) . '";',
      'before'
    );
  }

  // 「完了ページ(complete)」のときのみ履歴制御用スクリプトを読み込む
  if (is_page('complete')) {
    wp_enqueue_script(
      'contact-complete-js',
      get_theme_file_uri('/js/contact-complete.js'),
      array(),
      filemtime(get_theme_file_path('/js/contact-complete.js')),
      true
    );
  }
}
add_action('wp_enqueue_scripts', 'my_enqueue_contact_script_style');

// wp_mail() によるメール送信時の PHPMailer 設定を上書き
// SMTP サーバーの情報は wp-config.php に定義された定数を使用
add_action('phpmailer_init', function ($phpmailer) {
  $phpmailer->isSMTP(); // SMTP を使用する設定
  $phpmailer->Host       = defined('SMTP_HOST') ? SMTP_HOST : '';
  $phpmailer->SMTPAuth   = defined('SMTP_AUTH') ? SMTP_AUTH : true;
  $phpmailer->Port       = defined('SMTP_PORT') ? SMTP_PORT : 587;
  $phpmailer->Username   = defined('SMTP_USER') ? SMTP_USER : '';
  $phpmailer->Password   = defined('SMTP_PASS') ? SMTP_PASS : '';
  $phpmailer->SMTPSecure = defined('SMTP_SECURE') ? SMTP_SECURE : 'tls';
  $phpmailer->From       = defined('MAIL_FROM') ? MAIL_FROM : get_option('admin_email');
  $phpmailer->FromName   = defined('MAIL_FROM_NAME') ? MAIL_FROM_NAME : get_bloginfo('name');
});

// コンタクトフォーム用のセッションを開始(入力 → 確認 → 完了の3ページ間で値を保持するため)
function start_session_for_contact_form() {

  // 管理画面、Ajax、cron、XMLRPC など WordPress の特殊な処理時はセッションを開始しない
  if (is_admin() || wp_doing_ajax() || wp_doing_cron() || defined('XMLRPC_REQUEST')) {
    return;
  }

  // 「contact」「confirm」「complete」ページ以外ではセッションを使用しない
  if (!is_page(array('contact', 'confirm', 'complete'))) {
    return;
  }

  // まだセッションが開始されていない場合のみ開始
  if (session_status() === PHP_SESSION_NONE) {

    // セキュリティ対策として、セッション cookie の設定を明示的に指定
    session_set_cookie_params([
      'lifetime' => 0, // ブラウザを閉じるまで
      'path'     => COOKIEPATH,
      'domain'   => COOKIE_DOMAIN,
      'secure'   => is_ssl(), // HTTPS 通信時のみセキュア属性を付与
      'httponly' => true,     // JavaScript からアクセスできないようにする
      'samesite' => 'Lax',    // クロスサイト送信制限(セキュリティ強化)
    ]);

    session_start(); // セッション開始
  }
}
add_action('template_redirect', 'start_session_for_contact_form');

/**
 * メールアドレスをエンティティ化して mailto リンクとして出力するショートコード。
 *
 * このショートコードは、スパムボットによるメールアドレス収集を防ぐために、
 * `antispambot()` 関数を使ってメールアドレスをエンティティ化し、安全に mailto リンクを出力します。
 *
 * 使用例:
 * [email]foo@example.com[/email]
 *
 * 出力例:
 * <a href="mailto:&#101;&#120;&#97;&#109;&#112;&#108;&#101;&#64;&#101;&#120;&#97;&#109;&#112;&#108;&#101;&#46;&#99;&#111;&#109;">
 * &#101;&#120;&#97;&#109;&#112;&#108;&#101;&#64;&#101;&#120;&#97;&#109;&#112;&#108;&#101;&#46;&#99;&#111;&#109;
 * </a>
 *
 * @param array  $atts    ショートコードの属性(未使用)。
 * @param string $content ショートコード内の内容(メールアドレス)。
 * @return string|null    エンティティ化された mailto リンク、または無効な場合は null。
 */
function email_antispambot_shortcode( $atts , $content = null ) {
  if ( ! is_email( $content ) ) {
    return;
  }
  return '<a href="' . esc_url( 'mailto:' . antispambot( $content ) ) . '">' . esc_html( antispambot( $content ) ) . '</a>';
}
add_shortcode( 'email', 'email_antispambot_shortcode' );
  • filemtime() をバージョンとして使うことで、JSのキャッシュが強制的に更新されるので、開発・保守時に便利です。
  • wp_add_inline_script() を使うことで、PHP から JavaScript 変数を渡すことができます。
  • セッションのセキュリティ設定は、WordPress の仕様に合わせてカスタマイズすることで、CSRF やセッションハイジャックへの対策になります。
  • email_antispambot_shortcode() は投稿のコンテンツで、メールアドレスを表示させたいところに [email]foo@example.com[/email] のように記述すると安全に mailto リンクを出力します。

helpers.php

以下の PHP ファイルを作成します。この例では inc フォルダの中に contact フォルダを作成してその中に配置しています。

このファイルは、PHPで安全かつ柔軟なフォーム処理やセッション管理、Google reCAPTCHA v3の検証、リダイレクト処理を行うためのユーティリティ関数群を定義したものです。

お問い合わせフォームのような Web フォームを対象に、次のような共通処理をまとめています。

  • 入力値のエスケープ処理とバリデーション
  • セッション変数やPOSTデータの初期化
  • エラーメッセージ表示
  • メール設定の検証
  • 画面遷移時のリダイレクト
  • Google reCAPTCHA v3の検証
<?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')) {
  /**
   * wp-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;">wp-config.php に不正な定義があります:</h3>';
      echo '<ul>';
      foreach ($errors as $error) {
        echo '<li style="color:red;">' . htmlspecialchars($error, ENT_QUOTES, 'UTF-8') . '</li>';
      }
      echo '</ul>';
      exit('wp-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');
    // 以降の処理を終了(リダイレクト実行)
    exit;
  }
}

if (!function_exists('redirect_to_page')) {
  /**
   * 指定されたページへ 303 See Other リダイレクトを行う。
   * redirect_to_contact_input() が機能しない環境で使用することを想定
   *
   * - 現在のスクリプトのディレクトリに対して相対的なパスでページにリダイレクトする。
   * - HTTPS またはリバースプロキシ経由の HTTPS 判定にも対応。
   * - セッションロックを早期に解放してから Location ヘッダーでリダイレクトを行う。
   *
   * @param string $filename リダイレクト先のファイル名(デフォルトは 'contact')。
   *               先頭のスラッシュは自動的に除去される。
   *
   * @return void この関数はリダイレクト後にスクリプトを終了するため、戻り値はない。
   */
  function redirect_to_page($filename = 'contact') {
    // 現在実行中のスクリプトのパスからディレクトリ名を取得(例: /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" → "contact")
    $filename = ltrim($filename, '/');
    // リダイレクト先URLを構築(例: https://example.com/contact)
    $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; // 検証結果を返す
  }
}

1. h($var)

  • 入力値を htmlspecialchars() でエスケープする関数。
  • 配列も再帰的に処理。
  • null 対応済み(PHP 8.1対策)。

2. checkInput($var)

  • 入力値に対するセキュリティチェック(NULLバイト、文字エンコーディング、制御文字の除去)。
  • 不正なデータがあれば即 die() で終了。

3. init_session_value($key, $is_array = false)

  • セッション変数を安全に初期化。
  • 定義されていない場合は空文字列または空配列を返す。

4. init_post_value($key)

  • $_POST の値を取得してトリム処理。
  • 未定義なら空文字を返す。

5. print_error(array $errors, string $key)

  • エラー配列から対象キーのメッセージを出力。
  • 出力時に htmlspecialchars() によるエスケープを実行。

6. validate_mail_config(...)

  • wp-config.php に定義された定数(メールアドレス・名前・キー)を検証。
  • 不正があれば HTML で一覧表示し、スクリプトを停止。

7. redirect_to_contact_input()

  • 入力フォーム(contact.php)へリダイレクト。
  • セッションロックを解除し、HTTP 303 を用いてリダイレクト。

8. redirect_to_page($filename = 'contact')

  • 任意のファイルへリダイレクトする汎用関数。
  • HTTPS判定、ディレクトリ補正、セッションロック解除などに対応。

9. verify_recaptcha_v3(...)

  • Google reCAPTCHA v3 を検証。
  • curl によってGoogle APIと通信し、スコアの評価やトークンの整合性を確認。
  • 判定結果を ['status' => bool, 'message' => string] 形式で返却。

JavaScript

以下の3つの JavaScript ファイルを作成します。この例では js フォルダの中に配置しています。

contact-complete.js

フォームの再送信防止用のファイル ontact-complete.js を作成します。

完了ページで、再読み込みや「戻る」ボタンによって再送信が起きないようにする対策です。

// ブラウザの履歴対策(戻るボタンで再送信されないように)
// History API 対応ブラウザかを確認
if (window.history.replaceState) {
  // 状態とタイトルは変更せず、現在のURLで置き換える
  window.history.replaceState(null, null, window.location.href);
}

form-validation.js

フォームの入力値検証用のファイル form-validation.js を作成します。

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

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

使い方の詳細は「使い回せる JavaScript バリデーションの設計と実装」を御覧ください。

document.addEventListener("DOMContentLoaded", () => {
  // 設定オブジェクト
  const settings = {
    // バリデーション対象とするフォームのクラス名
    formSelector: ".js-form-validation",

    // エラー表示用の span 要素に付与するクラス名
    errorClassName: "error-js",

    // エラー表示を追加する親要素のクラス(省略可)
    errorContainerClass: ".error-container",

    // 各種検証項目に対応したデフォルトのエラーメッセージ
    messages: {
      required: "入力は必須です", // テキストやテキストエリアが未入力の場合
      requiredSelect: "選択は必須です", // チェックボックスやラジオボタン、セレクトボックスで未選択の場合
      pattern: "入力された値が正しくないようです", // パターン(正規表現)検証に失敗した場合
      equalTo: "入力された値が一致しません", // 他の項目と値が一致していない場合
      minlength: (min) => `${min}文字以上で入力してください`, // 指定文字数未満
      maxlength: (max) => `${max}文字以内で入力してください`, // 指定文字数超過
    },

    // 入力文字数カウント機能の見た目に関する設定
    counter: {
      wrapperClass: "count-span-wrapper", // カウント表示全体のラッパー要素に使うクラス
      countClass: "count-span", // 実際の文字数を表示する要素のクラス
      overLimitClass: "over-max-count", // 上限超過時に付与されるクラス(スタイル変更用)
      overLimitColor: "red", // 上限を超えた場合の文字色(インラインスタイルに設定。例 color: red)
    },

    // スクロール関連の設定
    scroll: {
      // スクロール位置のオフセット(data-error-offset が未指定の場合に使う)
      offset: 40,
      // スクロール動作("smooth"|"auto"|"instant")
      behavior: "smooth",
    },
  };

  // バリデーション対象の全てのフォーム要素を取得
  const validationForms = document.querySelectorAll(settings.formSelector);

  // バリデーション対象のフォームが1つもない場合は処理を終了
  if (!validationForms.length) return;

  // バリデーション対象の各フォーム要素ごとに処理
  validationForms.forEach((validationForm) => {
    // フォーム送信済みかどうかを示すフラグ(false の間はリアルタイム検証を無効化)
    let hasSubmittedOnce = false;

    // form 要素に data-realtime-validation="true" が指定されていれば送信前でもリアルタイムでの検証を行う
    if (validationForm.dataset.realtimeValidation === "true") {
      hasSubmittedOnce = true;
    }

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

    /**
     * エラーメッセージを削除する関数
     */
    const removeError = (elem, className) => {
      // error-container クラスの要素または親要素
      const errorContainer =
        elem.closest(settings.errorContainerClass) || elem.parentNode;
      const errorSpan = errorContainer.querySelector(
        `.${settings.errorClassName}.${className}`
      );
      if (errorSpan) errorSpan.remove();
    };

    /**
     * エラーメッセージを表示する関数
     */
    const addError = (elem, className, defaultMessage) => {
      // まだ送信していなければリアルタイム検証しない
      if (!hasSubmittedOnce) return;
      // すでに同じエラーがあれば削除(重複防止)
      removeError(elem, className);

      // data-error-[className] 属性に独自メッセージが指定されていればそれを優先、なければ defaultMessage を使用
      const errorMessage =
        elem.getAttribute(`data-error-${className}`) || defaultMessage;

      // エラーメッセージ用の要素を作成して追加
      const errorSpan = document.createElement("span");
      errorSpan.classList.add(settings.errorClassName, className);
      errorSpan.setAttribute("aria-live", "polite"); // 音声読み上げ対応
      errorSpan.textContent = errorMessage;
      // error-container クラスが指定されている親要素があればそこへ、なければ親要素にエラーを出力
      const errorContainer =
        elem.closest(settings.errorContainerClass) || elem.parentNode;
      errorContainer.appendChild(errorSpan);
    };

    // マルチバイト文字(サロゲートペア含む)対応の文字数カウント(絵文字・日本語などに対応)
    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);

        // 代表要素(最初の要素)
        const representative = group[0];

        if (!checked) {
          // 代表要素だけがエラーの表示/削除を行う(エラーメッセージの上書きを防ぐ)
          if (elem === representative) {
            addError(elem, className, settings.messages.requiredSelect);
          }
          return true;
        } else {
          if (elem === representative) {
            removeError(elem, className);
          }
          return false;
        }
      }

      // 通常の入力要素(text, select など)の場合
      if (!elem.value.trim()) {
        addError(
          elem,
          className,
          elem.tagName === "SELECT"
            ? settings.messages.requiredSelect
            : settings.messages.required
        );
        return true;
      }

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

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

      // 正規表現パターンを定義
      const patternRegistry = {
        tel: {
          pattern: /^0\d{9,10}$/,
          preprocess: (v) => v.replace(/-/g, ""), // ハイフン除去
        },
        email: {
          pattern:
            /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/,
        },
        zip: { pattern: /^\d{3}-\d{4}$/ }, // 郵便番号
        alphanum: { pattern: /^[a-zA-Z0-9]+$/ }, // 英数字
        kana: { pattern: /^[\u30A0-\u30FFー\s]+$/ }, // カタカナ
        // 必要に応じて他のパターンも追加可能
      };

      // 定義したパターンに patternType が含まれているか確認
      const def = patternRegistry[patternType];
      // 未定義のパターンタイプはチェックしない
      if (!def) return false;

      // 前処理(例: ハイフン除去など)
      if (typeof def.preprocess === "function") {
        value = def.preprocess(value);
      }

      // バリデーションを実行し、マッチしなければエラーを表示
      if (value && !def.pattern.test(value)) {
        addError(elem, className, settings.messages.pattern);
        return true;
      }

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

    /**
     * 他の入力と一致しているか(値一致の確認用)
     */
    const isNotEqualTo = (elem) => {
      const className = "equal-to";
      // data-equal-to 属性に指定された id を使って比較対象の要素を取得
      const target = document.getElementById(
        elem.getAttribute("data-equal-to")
      );
      // 両方に値があり、かつ不一致ならエラーを表示
      if (target && elem.value && target.value && elem.value !== target.value) {
        addError(elem, className, settings.messages.equalTo);
        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, `${settings.messages.minlength(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, `${settings.messages.maxlength(maxlength)}`);
        return true;
      }
      removeError(elem, className);
      return false;
    };

    /**
     * 入力文字数カウンター表示
     */
    const attachCounter = () => {
      showCountElems.forEach((elem) => {
        const max = parseInt(elem.getAttribute("data-maxlength"), 10);
        if (!isNaN(max) && !elem.dataset.hasCounter) {
          // 追加済みフラグを設定(同じ要素に複数回 append されるのを防ぐ)
          elem.dataset.hasCounter = "true";
          const countElem = document.createElement("p");
          countElem.classList.add(settings.counter.wrapperClass);
          const countClass = settings.counter.countClass;
          countElem.innerHTML = `<span class="${countClass}">0</span>/${max}`;
          elem.parentNode.appendChild(countElem);
          // 生成したカウント用の span 要素を取得
          const countSpan = countElem.querySelector(`.${countClass}`);

          // 文字数を更新する関数
          function updateCharCount() {
            const count = getValueLength(elem.value);
            countSpan.textContent = count;
            countSpan.classList.toggle(
              settings.counter.overLimitClass,
              count > max
            );
            countSpan.style.color =
              count > max ? settings.counter.overLimitColor : "";
          }

          // 初期状態でのカウント表示
          updateCharCount();
          // リアルタイムで文字数更新
          elem.addEventListener("input", updateCharCount);
        }
      });
    };

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

        // ラジオボタンまたはチェックボックスの場合
        if (elem.type === "radio" || elem.type === "checkbox") {
          const group = validationForm.querySelectorAll(
            `[name="${elem.name}"]`
          );
          // グループ内で1回だけイベント登録処理を行うための条件
          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 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 = validationForm.querySelector(
          `.${settings.errorClassName}`
        );
        // form 要素の data-error-offset を数値として取得(スクロールのオフセット調整)
        const offset =
          parseInt(validationForm.dataset.errorOffset, 10) ||
          settings.scroll.offset;
        if (errorElem) {
          window.scrollTo({
            top: errorElem.offsetTop - offset,
            behavior: settings.scroll.behavior,
          });
        }
      }
    });

    // バリデーションとカウンターを初期化
    attachValidation();
    attachCounter();
  });
});

recaptcha-v3-handler.js

Google reCAPTCHA v3 を組み込むためのファイル recaptcha-v3-handler.js を作成します。

このファイルは確認ページで reCAPTCHA v3 のトークンを取得し、フォームに埋め込んだうえでサーバーに送信します。

// ページ全体の 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)は、別途 functions.php で以下のように PHP 側から JS に値を渡します。

if (is_page('confirm')) {

  // wp-config.php に定義された reCAPTCHA サイトキーを取得(定義されていない場合は空文字)
  $site_key = defined('RECAPTCHA_V3_SITE_KEY') ? RECAPTCHA_V3_SITE_KEY : '';

  // Google の reCAPTCHA v3 API を読み込む(?render=サイトキー 付き)
  wp_enqueue_script(
    'google-recaptcha-v3',
    "https://www.google.com/recaptcha/api.js?render={$site_key}",
    array(),
    null, // バージョン(指定なし)
    true
  );

  // 自作の reCAPTCHA トークン処理用スクリプトを読み込む
  wp_enqueue_script(
    'recaptcha-v3-handler-js',
    get_theme_file_uri('/js/recaptcha-v3-handler.js'),
    array('google-recaptcha-v3'), // reCAPTCHA API に依存
    filemtime(get_theme_file_path('/js/recaptcha-v3-handler.js')),
    true
  );

  // JavaScript 内で reCAPTCHA サイトキーを参照できるようにグローバル変数として出力
  wp_add_inline_script(
    'recaptcha-v3-handler-js',  // データを渡す対象の JavaScript ファイルのハンドル名
    'window.recaptchaV3SiteKey = "' . esc_js($site_key) . '";',
    'before'
  );
}

入力ページ : contact.php

以下の contact.php を作成します。

このテンプレートファイルは、WordPress テーマ内で使用する「お問い合わせフォーム」の入力ページです。3ステップ構成(入力 → 確認 → 完了)の初期ステージにあたり、セキュリティ、UX、スパム対策、CSRF 対策などが施されています。

HTML のマークアップはサイトのテーマに合わせて変更・修正が必要になるかもしれません(確認ページや完了ページも同様です)。

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

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

// エスケープ処理やバリデーション、セッション補助関数などを含める
require get_theme_file_path('/inc/contact/helpers.php');

// wp-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'],           // オプションの名前定数
  ['RECAPTCHA_V3_SITE_KEY', 'RECAPTCHA_V3_SECRET_KEY']  // reCAPTCHA V3 サイトキーとシークレットキー
);

// セッション変数から値を取得(初回表示時は空、戻ったときは以前の入力値を復元)
$uname = init_session_value('uname');
$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'];

?>

<?php
/*
Template Name: お問い合わせページ
*/
get_header(); ?>

<main class="main-content contact">
  <section id="page-<?php the_ID(); ?>" <?php post_class(); ?>>
    <?php the_title('<h2 class="entry-title">', '</h2>'); ?>
    <div class="entry-content">
      <p>お問い合わせは以下のコンタクトフォームをお使いください。</p>
      <p>または <?php echo do_shortcode('[email]foo@example.com[/email]'); ?> までメールにてお問い合わせください。</p>
      <?php
      // 固定ページに記述したコンテンツを表示する場合(配置する場所は必要に応じて変更)
      while (have_posts()) :
        the_post();
        the_content();
      endwhile;
      ?>
      <?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="<?php echo esc_url(home_url('/confirm/')); ?>" novalidate>
        <!-- 以下の隠し要素にトークンを埋め込む -->
        <input type="hidden" name="csrf_token" value="<?php echo h($csrf_token); ?>">
        <div>
          <label for="uname">お名前(必須)
            <span class="error-php"><?php print_error($error, 'uname'); ?></span>
          </label>
          <input
            type="text"
            class="required maxlength"
            data-maxlength="30"
            id="uname"
            name="uname"
            placeholder="氏名"
            data-error-required="お名前は必須です。"
            value="<?php echo h($uname); ?>">
        </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 show-count"
            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><!-- .entry-content -->
  </section>
</main><!-- #main -->
<?php
get_footer();

主な機能と処理

1. セッション管理

  • session_regenerate_id() によりセッション固定攻撃を防止。
  • 独自に定義した init_session_value() 関数によって入力値の復元や初期化を行います。

2. HTTP ヘッダーによるセキュリティ

  • X-Frame-Options: SAMEORIGIN:クリックジャッキング対策。※古いブラウザ対応用(現在は非推奨)
  • Content-Security-Policy: frame-ancestors 'self';:最新ブラウザ向けの iframe 埋め込み制御。

※ これらは .htaccess で設定している場合は不要です。

X-Frame-Options は古い仕様であり、Content-Security-Policy(CSP)の frame-ancestors ディレクティブに置き換わりつつあり、現在は X-Frame-Options は非推奨になっています。

【注意】 .htaccess 側で Content-Security-Policy を設定している場合、それらの設定がこのヘッダーにより、上書きされてしまう(グローバル CSP が無効化される)可能性があるので注意が必要です。CSP の設定は .htaccessにまとめて管理するなどサイト全体で統一的に管理することが望ましいです。

3. 共通ファイルの読み込み

  • get_theme_file_path() を使用し、テーマの inc/contact/ ディレクトリにある共通関数を記述したファイル helpers.php 読み込む。
  • helpers.php には各ページで使用する関数が定義されています。このページでは validate_mail_config() や init_session_value() 関数を使用。

4. 設定ファイルの検証

  • validate_mail_config() 関数により、メール送信に必要な定数の定義状況をチェック。
    • 必須:MAIL_TO, MAIL_FROM, MAIL_RETURN_PATH, MAIL_TO_NAME, MAIL_FROM_NAME
    • 任意:MAIL_CC, MAIL_BCC, MAIL_CC_NAME, AUTO_REPLY_NAME, RECAPTCHA_V3_SITE_KEY, RECAPTCHA_V3_SECRET_KEY

5. CSRF 対策

  • random_bytes() でランダムなバイナリ文字列を生成し、bin2hex() で安全なトークン文字列に変換。
  • 生成した CSRF トークンは $_SESSION['csrf_token'] に保存。
  • フォーム送信時に <input type="hidden" name="csrf_token" ...> として埋め込む。

6. antispambot() の活用

  • WordPress の関数 antispambot() によるメールアドレスのエンティティ化でスパムボット対策。
  • [email]foo@example.com[/email] のショートコードで出力(functions.php で定義)。

7. フォーム構造とバリデーション

  • フォームには js-form-validation クラスと novalidate 属性を設定し、HTML5 の検証を無効化。
  • action 属性の送信先(確認ページ)は home_url('/confirm/') に指定。
  • 各 input 要素に検証のためのクラス属性(required や maxlength)を指定し、data-* 属性でエラーメッセージと制約を定義。
  • name 属性には WordPress の予約語(name)を避けるため、代わりに uname を使用。

8. エラー表示

  • PHP 側のバリデーションエラーは、セッションから取得し <span class="error-php"> に出力。
  • reCAPTCHA や送信エラーは、上部に <div class="error-php"> として表示。

9. 固定ページの本文挿入

  • the_content() により、編集画面で固定ページに記述された本文を表示可能。他のページでも同様。

現在はテンプレートに直接以下のように記述していますが、以下を削除して、

<p>または <?php echo do_shortcode('[email]foo@example.com[/email]'); ?> までメールにてお問い合わせください。</p>

本文に以下のようにショートコードや何らかのコンテンツを挿入することができます。

テンプレートの種類と構成

  • Template Name: お問い合わせページ と記述し、固定ページからテンプレート選択可能にしています。
  • テンプレート階層(例:page-contact.php)を使えば、テンプレート選択不要でより簡潔に運用可能。

確認ページ : confirm.php

以下の confirm.php を作成します。

このテンプレートファイルは、WordPress テーマ内における「お問い合わせフォーム」の確認ページです。入力内容の表示と確認、入力内容の検証、CSRF 対策、reCAPTCHA 連携に対応しており、送信前のステップを構成しています。

<?php
session_regenerate_id(); // セッションIDを更新

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

// 共通関数や設定ファイルを読み込み
require get_theme_file_path('/inc/contact/helpers.php');

// reCAPTCHA V3 サイトキーの取得(wp-config.php で定義)
$v3_site_key   = RECAPTCHA_V3_SITE_KEY;

// トークンを確認(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 は検査済み)
$uname    = init_post_value('uname');
$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 ($uname === '') {
  $error['uname'] = '*お名前は必須項目です。';
} elseif (preg_match("/[\r\n]/", $uname)) {
  $error['uname'] = '*お名前に改行文字は使用できません。';
} elseif (mb_strlen($uname, 'UTF-8') > 30) {
  $error['uname'] = '*お名前は30文字以内でお願いします。';
}

if ($email === '') {
  $error['email'] = '*メールアドレスは必須です。';
} elseif (!preg_match('/^[a-zA-Z0-9.!#$%&\'*+\/=?^_`{|}~-]+@' .
              '[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?' .
              '(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/', $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("/[\r\n]/", $subject)) {
  $error['subject'] = '*件名に改行文字は使用できません。';
} elseif (mb_strlen($subject, 'UTF-8') > 100) {
  $error['subject'] = '*件名は100文字以内でお願いします。';
}

if ($body === '') {
  $error['body'] = '*内容は必須項目です。';
} elseif (mb_strlen($body, 'UTF-8') > 1000) {
  $error['body'] = '*内容は1000文字以内でお願いします。';
}

//POSTされたデータとエラーの配列をセッション変数に保存
$_SESSION['uname'] = $uname;
$_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'];
?>
<?php
/*
Template Name: お問い合わせ 確認ページ
*/
get_header(); ?>
<main class="main-content contact confirm">
  <section id="page-<?php the_ID(); ?>" <?php post_class(); ?>>
    <?php the_title('<h2 class="entry-title">', '</h2>'); ?>
    <div class="entry-content">
      <p>以下の内容でよろしければ「送信」をクリックしてください。</p>
      <p>内容を変更する場合は「戻る」をクリックして入力画面にお戻りください。</p>
      <?php
      // 固定ページに記述したコンテンツを表示する場合(配置する場所は必要に応じて変更)
      while (have_posts()) :
        the_post();
        the_content();
      endwhile;
      ?>
      <div class="confirm-table-wrapper">
        <table class="confirm-table">
          <caption>ご入力内容</caption>
          <tr>
            <th>お名前</th>
            <td><?php echo h($uname); ?></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="<?php echo esc_url(home_url('/contact/')); ?>" method="post" class="confirm back">
          <button type="submit" class="form-button">戻る</button>
        </form>
        <form action="<?php echo esc_url(home_url('/complete/')); ?>" method="post" class="confirm send rcv3">
          <!-- 完了画面(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>
  </section>
</main>
 <!-- reCAPTCHA サイトキーの出力、Google reCAPTCHA API 及び recaptcha-v3-handler.js の読み込みは functions.php に記述 -->
<?php
get_footer();

主な機能と処理

1. セッションID更新

  • session_regenerate_id() を呼び出してセッション固定攻撃を防止。

2. セキュリティヘッダー出力

  • X-Frame-Options: SAMEORIGIN(現在は非推奨)と Content-Security-Policy: frame-ancestors 'self'; により、クリックジャッキングを防止(.htaccess 側で出力している場合は不要)。

3. 共通ファイルの読み込み

  • get_theme_file_path('/inc/contact/helpers.php') で、テーマの inc/contact/ ディレクトリにある共通関数(checkInput() や init_post_value())のファイルを読み込む。

4. CSRF 対策(トークン検証)

  • 入力ページから送信された CSRF トークンを hash_equals() で安全に照合。
  • 不一致または未送信の場合は、不正アクセス扱いでエラー終了または入力ページにリダイレクト。
  • トークンは1回限り有効であり、使用後は即 unset() によって無効化。

5. POSTデータのサニタイズと検査

  • checkInput() によるグローバルな入力検査(HTMLタグ除去・無効文字除去など)。
  • init_post_value() で個々のフォーム値を取り出しつつ、セッション保持。

6. バリデーション

  • 入力チェックをサーバーサイドで再実施。
  • 以下のルールで入力を検証し、エラーがあれば $error 配列に格納:
    • uname: 必須、30文字以内、改行禁止
    • email: 必須、メール形式、改行禁止
    • email_check: 必須、一致確認
    • tel: 任意、日本の市外局番を含む10~11桁(ハイフンあり/なし両対応)
    • subject: 必須、100文字以内
    • body: 必須、1000文字以内(改行・タブ可)

7. バリデーションエラー時の遷移制御

  • $error が存在する場合は入力ページへリダイレクトし、セッション経由でエラーを再表示。

8. セッションへの値保存

  • 検証通過後、フォームデータと $error を $_SESSION に保存。
  • 完了ページ(complete.php)から再利用される。

9. ワンタイムトークン再生成

  • 再度 bin2hex(random_bytes(32)) による CSRF トークンを生成し、次ステップ(送信)に備える。

2つのアクションフォーム

  • 「戻る」ボタン:入力ページ (/contact/) に戻る(POST)
  • 「送信」ボタン:完了ページ (/complete/) へ遷移(POST)
    • csrf_token を hidden フィールドで送信
    • rcv3 クラスを付与し、reCAPTCHA v3 対応 JS(recaptcha-v3-handler.js)が連動可能

テンプレート指定

コメントブロックの Template Name: お問い合わせ 確認ページ により、固定ページから「お問い合わせ 確認ページ」としてテンプレート選択可能。

その他

  • h() 関数(helpers.php)により、XSS 対策としてサニタイズ済みの値を出力。
  • nl2br() を使い、body(本文)の改行を維持して表示。
  • functions.php で Google reCAPTCHA v3 の JavaScript 出力やキー埋め込み処理が行われる

完了ページ : complete.php

以下の complete.php を作成します。

このテンプレートファイルは、WordPressテーマ内で構築された3ステップお問い合わせフォームの「送信完了ページ」です。フォーム送信後のセキュリティ検証、メール送信、自動返信、セッション破棄を一括して処理するページです。

<?php
// header('X-Frame-Options: SAMEORIGIN'); // 現在は非推奨
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 get_theme_file_path('/inc/contact/helpers.php');

// reCAPTCHA V3 サイトキーとシークレットキーの取得(wp-config.php で定義)
$v3_site_key   = RECAPTCHA_V3_SITE_KEY;
$v3_secret_key = RECAPTCHA_V3_SECRET_KEY;

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

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

// POST データの安全性をチェック(サニタイズ)
$_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'] ?? '';

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

    // 宛先メールアドレスと宛名
    $mailTo = MAIL_TO_NAME . " <" . MAIL_TO . ">";

    // ヘッダー配列を用意
    $headers = [];
    $headers[] = 'Content-Type: text/plain; charset=UTF-8';
    $headers[] = 'From: ' . MAIL_FROM_NAME . ' <' . MAIL_FROM . '>';
    $headers[] = 'Reply-To: ' . $uname . ' <' . $email . '>';

    if (defined('MAIL_CC_NAME') && defined('MAIL_CC') && MAIL_CC !== '') {
      $headers[] = 'Cc: ' . MAIL_CC_NAME . ' <' . MAIL_CC . '>';
    }
    if (defined('MAIL_BCC') && MAIL_BCC !== '') {
      $headers[] = 'Bcc: <' . MAIL_BCC . '>';
    }

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

    // メール送信
    $result = wp_mail($mailTo, $subject, $mail_body, $headers);

    // 自動返信メール送信結果の初期化
    $reply_result = false;

    // 自動返信メール(オプション)
    if ($result) {
      if (defined('AUTO_REPLY_ENABLED') && AUTO_REPLY_ENABLED && defined('AUTO_REPLY_NAME')) {
        // mb_language() や mb_internal_encoding() は不要(wp_mail は UTF-8 が標準)

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

        $reply_body = <<<EOT
{$uname} 様

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

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

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

<お問い合わせ内容>
{$body}
EOT;

        // ヘッダーを配列で用意
        $reply_headers = [
          'Content-Type: text/plain; charset=UTF-8',
          'From: ' . AUTO_REPLY_NAME . ' <' . MAIL_FROM . '>',
          'Reply-To: ' . AUTO_REPLY_NAME . ' <' . MAIL_TO . '>',
        ];

        // 自動返信メール送信
        $reply_result = wp_mail($email, $reply_subject, $reply_body, $reply_headers);
      }
    } 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;
}
?>
<?php
/*
Template Name: お問い合わせ 完了ページ
*/
get_header(); ?>
<main class="main-content contact complete">
  <section id="page-<?php the_ID(); ?>" <?php post_class(); ?>>
    <?php the_title('<h2 class="entry-title">', '</h2>'); ?>
    <div class="entry-content">
      <h3 aria-label="送信完了メッセージ">送信が完了しました。</h3>
      <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; ?>
      <?php
      // 固定ページに記述したコンテンツを表示する場合(配置する場所は必要に応じて変更)
      while (have_posts()) :
        the_post();
        the_content();
      endwhile;
      ?>

    </div>
  </section>
</main>
<!-- ブラウザの履歴対策(戻るボタンで再送信されないように)の JavaScript は別途ファイルに定義 -->
<?php
get_footer();

主な機能と処理

1. セキュリティヘッダーとキャッシュ制御

  • X-Frame-Options: SAMEORIGIN(現在は非推奨)と Content-Security-Policy: frame-ancestors 'self'; により、クリックジャッキングを防止(.htaccess 側で出力している場合は不要)。
  • Cache-Control, Pragma, Expires ヘッダーを指定してブラウザに完了画面をキャッシュさせず、戻るボタンで再送信が起きるのを防止。

2. 共通ファイルの読み込み

  • get_theme_file_path('/inc/contact/helpers.php') で、テーマの inc/contact/ ディレクトリにある共通関数(バリデーション、reCAPTCHA検証など)のファイルを読み込む。

3. 設定の読み込み

  • wp-config.php に定義された定数を使用し、reCAPTCHAやデバッグ情報出力の設定を反映。

4. POSTリクエストとCSRFトークンの検証

  • POST リクエストでのアクセスでない場合は、入力ページへリダイレクト
  • 送信された CSRF トークンを hash_equals() で厳密にチェック
  • トークンが無効または欠損していたら即終了かリダイレクト

5. reCAPTCHA v3 の検証

  • helpers.php で定義した reCAPTCHA v3 を検証する関数 verify_recaptcha_v3() を呼び出して、ユーザーがbotでないかを確認
  • この例では spam 判定しきい値を0.7に設定(環境に応じて変更)

6. メール本文とヘッダーの準備・送信

  • WordPress 標準の wp_mail() で送信。
  • MAIL_CC や MAIL_BCC が定義されていればそれも利用。

7. 自動返信メールの送信(オプション)

  • 自動返信機能が有効な(AUTO_REPLY_ENABLED が true で AUTO_REPLY_NAME が定義されている)場合、送信者宛に自動返信メール。
  • 送信時刻や入力内容も反映。

8. 送信失敗・reCAPTCHA失敗時の処理

  • メール送信に失敗した場合や reCAPTCHA でスパムと判定された場合は、入力ページにリダイレクトしてユーザー向けにはエラーメッセージを出力
  • 管理者向けには error_log() で記録。

9. セッションのクリア

  • 完了後、セッション情報を完全削除(再送信防止にも寄与)。

10. HTML部分:送信完了メッセージの出力

  • 視覚的な完了メッセージを表示。
  • SHOW_RECAPTCHA_V3_RESULT が true なら、判定スコアなどのテスト表示も可能(デバッグ用)。

11. 自動返信結果の表示

  • 自動返信結果をユーザーへのフィードバック表示。

12. 固定ページの本文挿入

  • the_content() により、編集画面で固定ページに記述されたコンテンツ(例:お礼文や画像など)を表示可能。

管理画面から固定ページを作成

管理画面「固定ページ > 新規追加」でそれぞれの固定ページを新規に作成します。

タイトルは任意ですが、スラッグとテンプレートはそれぞれ以下を指定します。

作成する固定ページ スラッグ テンプレート
入力ページ contact お問い合わせページ
確認ページ confirm お問い合わせ 確認ページ
完了ページ complete お問い合わせ 完了ページ

必要に応じて本文(コンテンツ)も挿入可能です。

入力ページ

確認ページ

完了ページ

スタイル style.css

必要に応じてスタイルを style.css などに追加します。

/**
*
*  contact form pages
*
**/

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

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

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

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

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

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

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

@media screen and (max-width: 480px) {
  .contact {
    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;
}

.contact .error-php,
.contact .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;
}

上記のスタイルを適用すると、例えば、以下のような表示になります。

ブロックテーマ対応版のサンプル

以下はブロックテーマに対応したお問い合わせフォームのサンプルです。

最低限の機能は備えていますが、実用にはまだ改良の余地が多くあります。あくまで個人的な制作メモとしてご覧ください。

基本的な構成は、クラシックテーマでの実装とほぼ同じですが、ブロックテーマではテンプレートを /templates/ フォルダ内の HTML ファイルとして定義する必要があります。

また、HTML テンプレート内には PHP を直接書けないため、フォームの表示やメール送信などのサーバーサイド処理は、ショートコードを使って別途実装しています。

より適切な実装方法があるかもしれませんが、その点はご容赦ください。

以下では Create Block Theme プラグインを使って、Twenty Twenty-Five の子テーマ(my2025)を作成し、そのテーマにお問い合わせページを追加します。

関連ページ:Create Block Theme プラグインの使い方

例えば、Create Block Theme プラグインを使って、my2025 という名前の Twenty Twenty-Five の子テーマを作成すると、以下のようなディレクトリ構成が生成されるので、必要なディレクトリやファイルを追加します。

my2025
  ├─ readme.txt
  ├─ screenshot.png
  ├─ style.css
  └─ theme.json

ファイル構成

以下は、ブロックテーマ版のお問い合わせフォームで使用するファイル構成です。

本サンプルはクラシックテーマ版と同様、3ページ構成(入力・確認・完了)となっており、テンプレートファイルはすべて HTML 形式で /templates/ フォルダに配置します。

使用するテンプレートファイル(全3ページ構成)

  • 入力ページ : page-contact.html
  • 確認ページ : page-confirm.html
  • 完了ページ : page-complete.html

すべて page-{slug}.html 形式で作成し、テンプレート階層に従ったファイル名にしています。

この形式にすることで、固定ページにそれぞれ contact、confirm、complete といったスラッグを設定すれば、WordPress が対応するテンプレートファイルを自動的に適用します。theme.json への追加設定(customTemplates の登録)などは不要です。

その他の作成ファイル

以下のファイルはクラシックテーマ版と同じなので内容は省略します。

その他の設定ファイル・変更点

  • functions.php:各ページの PHP 処理のショートコードの定義を追加
  • wp-config.php:情報を定数として定義(クラシックテーマ版と同じなので省略)
  • .htaccess:wp-config.php の保護(クラシックテーマ版と同じなので省略)

ディレクトリ構成例(Twenty Twenty-Five の子テーマを作成)

assets と includes フォルダを追加し、上記のファイルを作成(コピー)して配置します。また、templates フォルダと空の functions.php を作成しておき、wp-config.php にメール送信先やパスワードなどの情報を定数として定義します。

my2025
  ├─ assets
  │   ├─ css
  │   │   └─ contact-form-style.css  # お問い合わせページのスタイル
  │   └─ js
  │       ├─ contact-complete.js      # ブラウザの履歴対策
  │       ├─ form-validation.js       # フォームのバリデーション処理
  │       └─ recaptcha-v3-handler.js  # reCAPTCHA V3 のトークン処理
  ├─ functions.php     # JS の読み込み、セッション処理、ショートコードなどを追加
  ├─ includes
  │   └─ helpers.php  # 共通関数(入力値検証やエスケープ処理、reCAPTCHA 検証などの関数群)
  ├─ readme.txt
  ├─ screenshot.png
  ├─ style.css
  ├─ templates
  │   ├─ page-complete.html  # 完了ページテンプレート
  │   ├─ page-confirm.html   # 確認ページテンプレート
  │   └─ page-contact.html   # 入力ページテンプレート
  └─ theme.json
.
.
wp-config.php # メール送信先や reCAPTCHA キー、パスワードなどの情報を定数として定義
.htaccess # wp-config.php を保護する記述を追加

備考

JS や CSS ファイルは assets/ フォルダに、共通の PHP 関数(helpers.php)は includes/ フォルダに配置しています。この点がクラシックテーマ版との主な違いです。

functions.php

以下を functions.php に追加します。

前述のクラシックテーマ版の functions.php と基本的には同じですが、ファイル読み込みのパスや共通スタイルの読み込みなど一部変更になっています。ショートコードの記述は後から追加します。

<?php

// テーマで使用する JavaScript およびスタイルシートの読み込み処理
function my_enqueue_contact_script_style() {

  // 「お問い合わせページ(contact)」のときのみバリデーション用スクリプトを読み込む
  if (is_page('contact')) {
    wp_enqueue_script(
      'form-validation-js', // ハンドル名
      get_theme_file_uri('/assets/js/form-validation.js'), // ファイルの URL
      array(), // 依存スクリプト(なし)
      filemtime(get_theme_file_path('/assets/js/form-validation.js')), // キャッシュ対策:更新時刻をバージョンとして指定
      true // フッターに出力
    );
  }

  // 「確認ページ(confirm)」のときにのみ reCAPTCHA v3 用スクリプトを読み込む
  if (is_page('confirm')) {

    // wp-config.php に定義された reCAPTCHA サイトキーを取得(定義されていない場合は空文字)
    $site_key = defined('RECAPTCHA_V3_SITE_KEY') ? RECAPTCHA_V3_SITE_KEY : '';

    // Google の reCAPTCHA v3 API を読み込む(?render=サイトキー 付き)
    wp_enqueue_script(
      'google-recaptcha-v3',
      "https://www.google.com/recaptcha/api.js?render={$site_key}",
      array(),
      null, // バージョン(指定なし)
      true
    );

    // 自作の reCAPTCHA トークン処理用スクリプトを読み込む
    wp_enqueue_script(
      'recaptcha-v3-handler-js',
      get_theme_file_uri('/assets/js/recaptcha-v3-handler.js'),
      array('google-recaptcha-v3'), // reCAPTCHA API に依存
      filemtime(get_theme_file_path('/assets/js/recaptcha-v3-handler.js')),
      true
    );

    // JavaScript 内で reCAPTCHA サイトキーを参照できるようにグローバル変数として出力
    wp_add_inline_script(
      'recaptcha-v3-handler-js',
      'window.recaptchaV3SiteKey = "' . esc_js($site_key) . '";',
      'before'
    );
  }

  // 「完了ページ(complete)」のときのみ履歴制御用スクリプトを読み込む
  if (is_page('complete')) {
    wp_enqueue_script(
      'contact-complete-js',
      get_theme_file_uri('/assets/js/contact-complete.js'),
      array(),
      filemtime(get_theme_file_path('/assets/js/contact-complete.js')),
      true
    );
  }

  // style.css の読み込み
  wp_enqueue_style(
    'contact-form-style',
    get_theme_file_uri('/assets/css/contact-form-style.css'),
    array(),
    filemtime(get_theme_file_path('/assets/css/contact-form-style.css'))
  );
}
add_action('wp_enqueue_scripts', 'my_enqueue_contact_script_style');


// コンタクトフォーム用のセッションを開始(入力 → 確認 → 完了の3ページ間で値を保持するため)
function start_session_for_contact_form() {

  // 管理画面、Ajax、cron、XMLRPC など WordPress の特殊な処理時はセッションを開始しない
  if (is_admin() || wp_doing_ajax() || wp_doing_cron() || defined('XMLRPC_REQUEST')) {
    return;
  }

  // 「contact」「confirm」「complete」ページ以外ではセッションを使用しない
  if (!is_page(array('contact', 'confirm', 'complete'))) {
    return;
  }

  // まだセッションが開始されていない場合のみ開始
  if (session_status() === PHP_SESSION_NONE) {

    // セキュリティ対策として、セッション cookie の設定を明示的に指定
    session_set_cookie_params([
      'lifetime' => 0, // ブラウザを閉じるまで
      'path'     => COOKIEPATH,
      'domain'   => COOKIE_DOMAIN,
      'secure'   => is_ssl(), // HTTPS 通信時のみセキュア属性を付与
      'httponly' => true,     // JavaScript からアクセスできないようにする
      'samesite' => 'Lax',    // クロスサイト送信制限(セキュリティ強化)
    ]);

    session_start(); // セッション開始
  }
}
add_action('template_redirect', 'start_session_for_contact_form');

// wp_mail() によるメール送信時の PHPMailer 設定を phpmailer_init で上書き
// SMTP サーバーの情報は wp-config.php に定義された定数を使用
add_action('phpmailer_init', function ($phpmailer) {
  $phpmailer->isSMTP();
  $phpmailer->Host       = defined('SMTP_HOST') ? SMTP_HOST : '';
  $phpmailer->SMTPAuth   = defined('SMTP_AUTH') ? SMTP_AUTH : true;
  $phpmailer->Port       = defined('SMTP_PORT') ? SMTP_PORT : 587;
  $phpmailer->Username   = defined('SMTP_USER') ? SMTP_USER : '';
  $phpmailer->Password   = defined('SMTP_PASS') ? SMTP_PASS : '';
  $phpmailer->SMTPSecure = defined('SMTP_SECURE') ? SMTP_SECURE : 'tls';
  $phpmailer->From       = defined('MAIL_FROM') ? MAIL_FROM : get_option('admin_email');
  $phpmailer->FromName   = defined('MAIL_FROM_NAME') ? MAIL_FROM_NAME : get_bloginfo('name');
});

/**
* メールアドレスをエンティティ化して mailto リンクとして出力するショートコード。
*
* @param array  $atts    ショートコードの属性(未使用)。
* @param string $content ショートコード内の内容(メールアドレス)。
* @return string|null    エンティティ化された mailto リンク、または無効な場合は null。
*
* 使用例:
* [email]foo@example.com[/email]
*
*/
function email_antispambot_shortcode($atts, $content = null) {
  if (! is_email($content)) {
    return;
  }
  return '<a href="' . esc_url('mailto:' . antispambot($content)) . '">' . esc_html(antispambot($content)) . '</a>';
}
add_shortcode('email', 'email_antispambot_shortcode');

HTML テンプレート

HTML テンプレートはサイトエディターで作成することもできますが、この例では Twenty Twenty-Five のデフォルトの固定ページテンプレート(twentytwentyfive/templates/page.html)をコピーして、それを基(もと)に作成します。

<!-- wp:template-part {"slug":"header"} /-->

<!-- wp:group {"tagName":"main","style":{"spacing":{"margin":{"top":"var:preset|spacing|60"}}},"layout":{"type":"constrained"}} -->
<main class="wp-block-group" style="margin-top:var(--wp--preset--spacing--60)">
  <!-- wp:group {"align":"full","style":{"spacing":{"padding":{"top":"var:preset|spacing|60","bottom":"var:preset|spacing|60"}}},"layout":{"type":"constrained"}} -->
  <div class="wp-block-group alignfull" style="padding-top:var(--wp--preset--spacing--60);padding-bottom:var(--wp--preset--spacing--60)">
    <!-- wp:post-featured-image {"style":{"spacing":{"margin":{"bottom":"var:preset|spacing|60"}}}} /-->
    <!-- wp:post-title {"level":1} /-->
    <!-- wp:post-content {"align":"full","layout":{"type":"constrained"}} /-->
  </div>
  <!-- /wp:group -->
</main>
<!-- /wp:group -->

<!-- wp:template-part {"slug":"footer"} /-->

入力ページ : page-contact.html

以下は page-contact.html です。

タイトルとコンテンツの後にショートコード [contact_form_input] を追加しています。

<!-- wp:template-part {"slug":"header"} /-->

<!-- wp:group {"tagName":"main","style":{"spacing":{"margin":{"top":"var:preset|spacing|60"}}},"layout":{"type":"constrained"}} -->
<main class="wp-block-group" style="margin-top:var(--wp--preset--spacing--60)">
  <!-- wp:group {"align":"full","style":{"spacing":{"padding":{"top":"var:preset|spacing|60","bottom":"var:preset|spacing|60"}}},"layout":{"type":"constrained"}} -->
  <div class="wp-block-group alignfull" style="padding-top:var(--wp--preset--spacing--60);padding-bottom:var(--wp--preset--spacing--60)">
    <!-- wp:post-title {"level":1} /-->
    <!-- wp:post-content {"align":"full","layout":{"type":"constrained"}} /-->
    <!-- wp:shortcode -->
    [contact_form_input]
    <!-- /wp:shortcode -->
  </div>
  <!-- /wp:group -->
</main>
<!-- /wp:group -->

<!-- wp:template-part {"slug":"footer"} /-->
[contact_form_input]

ショートコード contact_form_input の定義を functions.php に追加します。

以下のショートコードの内容は contact.php の内容とほぼ同じですが以下の部分が異なります。

  • ショートコードはページの途中で呼ばれるため、header() を呼ぶと headers already sent エラーになることがあるので、headers_sent() で確認して出力
  • ディレクトリ構成変更に伴い、helpers.php の読み込むパスを変更
  • タイトルやコンテンツはテンプレートファイルで出力しているので、ショートコード内での the_title() や the_content() の呼び出しは不要
  • メッセージ部分も編集画面から入力するようにし、ショートコードからは削除
  • HTML マークアップもテンプレートファイルに合わせて main や section は削除
// 入力ページのショートコード
add_shortcode('contact_form_input', function () {
  ob_start();

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

  // ヘッダが既に送信されていなければ(headers already sent エラー防止)
  if (!headers_sent()) {
    // クリックジャッキング対策(.htaccess 側でも設定している場合は不要)
    header("Content-Security-Policy: frame-ancestors 'self';");
  }

  // エスケープ処理やバリデーション、セッション補助関数などを含める
  require get_theme_file_path('/includes/helpers.php');

  // wp-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'],           // オプションの名前定数
    ['RECAPTCHA_V3_SITE_KEY', 'RECAPTCHA_V3_SECRET_KEY']  // reCAPTCHA V3 サイトキーとシークレットキー
  );

  // セッション変数から値を取得(初回表示時は空、戻ったときは以前の入力値を復元)
  $uname = init_session_value('uname');
  $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'];

?>
  <div class="contact">
    <?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="<?php echo esc_url(home_url('/confirm/')); ?>" novalidate>
      <!-- 以下の隠し要素にトークンを埋め込む -->
      <input type="hidden" name="csrf_token" value="<?php echo h($csrf_token); ?>">
      <div>
        <label for="uname">お名前(必須)
          <span class="error-php"><?php print_error($error, 'uname'); ?></span>
        </label>
        <input
          type="text"
          class="required maxlength"
          data-maxlength="30"
          id="uname"
          name="uname"
          placeholder="氏名"
          data-error-required="お名前は必須です。"
          value="<?php echo h($uname); ?>">
      </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 show-count"
          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><!-- .contact -->
<?php

  return ob_get_clean();
});

確認ページ : page-confirm.html

以下は page-confirm.html です。

ショートコード [contact_form_confirm] 以外の部分は page-contact.html と同じです。

<!-- wp:template-part {"slug":"header"} /-->

<!-- wp:group {"tagName":"main","style":{"spacing":{"margin":{"top":"var:preset|spacing|60"}}},"layout":{"type":"constrained"}} -->
<main class="wp-block-group" style="margin-top:var(--wp--preset--spacing--60)">
  <!-- wp:group {"align":"full","style":{"spacing":{"padding":{"top":"var:preset|spacing|60","bottom":"var:preset|spacing|60"}}},"layout":{"type":"constrained"}} -->
  <div class="wp-block-group alignfull" style="padding-top:var(--wp--preset--spacing--60);padding-bottom:var(--wp--preset--spacing--60)">
    <!-- wp:post-title {"level":1} /-->
    <!-- wp:post-content {"align":"full","layout":{"type":"constrained"}} /-->
    <!-- wp:shortcode -->
    [contact_form_confirm]
    <!-- /wp:shortcode -->
  </div>
  <!-- /wp:group -->
</main>
<!-- /wp:group -->

<!-- wp:template-part {"slug":"footer"} /-->
[contact_form_confirm]

以下のショートコード contact_form_confirm の定義を functions.php に追加します。

ショートコードの内容は confirm.php の内容とほぼ同じですが、以下の部分が異なります。

  • ヘッダの出力は headers already sent エラー防止対策として、headers_sent() で確認して出力
  • ディレクトリ構成変更に伴い、helpers.php の読み込むパスを変更
  • ショートコード内での the_title() や the_content() の呼び出しは削除(テンプレートファイルで出力)
  • メッセージ部分も編集画面から入力するようにし、ショートコードからは削除
  • HTML マークアップもテンプレートファイルに合わせて main や section は削除
// 確認ページのショートコード
add_shortcode('contact_form_confirm', function () {
  ob_start();

  session_regenerate_id(); // セッションIDを更新

  // ヘッダが既に送信されていなければ(headers already sent エラー防止)
  if (!headers_sent()) {
    // クリックジャッキング対策(.htaccess 側でも設定している場合は不要)
    header("Content-Security-Policy: frame-ancestors 'self';");
  }

  // 共通関数や設定ファイルを読み込み
  require get_theme_file_path('/includes/helpers.php');

  // reCAPTCHA V3 サイトキーの取得(wp-config.php で定義)
  $v3_site_key   = RECAPTCHA_V3_SITE_KEY;

  // トークンを確認(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 は検査済み)
  $uname    = init_post_value('uname');
  $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 ($uname === '') {
    $error['uname'] = '*お名前は必須項目です。';
  } elseif (preg_match("/[\r\n]/", $uname)) {
    $error['uname'] = '*お名前に改行文字は使用できません。';
  } elseif (mb_strlen($uname, 'UTF-8') > 30) {
    $error['uname'] = '*お名前は30文字以内でお願いします。';
  }

  if ($email === '') {
    $error['email'] = '*メールアドレスは必須です。';
  } elseif (!preg_match('/^[a-zA-Z0-9.!#$%&\'*+\/=?^_`{|}~-]+@' .
                '[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?' .
                '(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/', $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("/[\r\n]/", $subject)) {
    $error['subject'] = '*件名に改行文字は使用できません。';
  } elseif (mb_strlen($subject, 'UTF-8') > 100) {
    $error['subject'] = '*件名は100文字以内でお願いします。';
  }

  if ($body === '') {
    $error['body'] = '*内容は必須項目です。';
  } elseif (mb_strlen($body, 'UTF-8') > 1000) {
    $error['body'] = '*内容は1000文字以内でお願いします。';
  }

  //POSTされたデータとエラーの配列をセッション変数に保存
  $_SESSION['uname'] = $uname;
  $_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'];

?>
  <div class="contact confirm">
    <div class="confirm-table-wrapper">
      <table class="confirm-table">
        <caption>ご入力内容</caption>
        <tr>
          <th>お名前</th>
          <td><?php echo h($uname); ?></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="<?php echo esc_url(home_url('/contact/')); ?>" method="post" class="confirm back">
        <button type="submit" class="form-button">戻る</button>
      </form>
      <form action="<?php echo esc_url(home_url('/complete/')); ?>" method="post" class="confirm send rcv3">
        <button type="submit" class="form-button">送信する</button>
        <!-- 完了画面(complete.php)用のCSRFトークンの隠しフィールド -->
        <input type="hidden" name="csrf_token" value="<?php echo h($csrf_token); ?>">
      </form>
    </div>
  </div>
<?php

  return ob_get_clean();
});

完了ページ : page-complete.html

以下は page-complete.html です。

この例では、ショートコード [contact_form_complete] の後にコンテンツを出力するようにしています。

<!-- wp:template-part {"slug":"header"} /-->

<!-- wp:group {"tagName":"main","style":{"spacing":{"margin":{"top":"var:preset|spacing|60"}}},"layout":{"type":"constrained"}} -->
<main class="wp-block-group" style="margin-top:var(--wp--preset--spacing--60)">
  <!-- wp:group {"align":"full","style":{"spacing":{"padding":{"top":"var:preset|spacing|60","bottom":"var:preset|spacing|60"}}},"layout":{"type":"constrained"}} -->
  <div class="wp-block-group alignfull" style="padding-top:var(--wp--preset--spacing--60);padding-bottom:var(--wp--preset--spacing--60)">
    <!-- wp:post-title {"level":1} /-->
    <!-- wp:shortcode -->
    [contact_form_complete]
    <!-- /wp:shortcode -->
    <!-- wp:post-content {"align":"full","layout":{"type":"constrained"}} /-->
  </div>
  <!-- /wp:group -->
</main>
<!-- /wp:group -->

<!-- wp:template-part {"slug":"footer"} /-->
[contact_form_complete]

以下のショートコード contact_form_complete の定義を functions.php に追加します。

ショートコードの内容は complete.php の内容とほぼ同じですが、以下の部分が異なります。

  • ヘッダの出力は headers already sent エラー防止対策として、headers_sent() で確認して出力
  • ディレクトリ構成変更に伴い、helpers.php の読み込むパスを変更
  • ショートコード内での the_title() や the_content() の呼び出しは削除(テンプレートファイルで出力)
  • HTML マークアップもテンプレートファイルに合わせて main や section は削除
// 完了ページのショートコード
add_shortcode('contact_form_complete', function () {
  ob_start();

  // ヘッダが既に送信されていなければ(headers already sent エラー防止)
  if (!headers_sent()) {
    // クリックジャッキング対策(.htaccess 側でも設定している場合は不要)
    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 get_theme_file_path('/includes/helpers.php');

  // reCAPTCHA V3 サイトキーとシークレットキーの取得(wp-config.php で定義)
  $v3_site_key   = RECAPTCHA_V3_SITE_KEY;
  $v3_secret_key = RECAPTCHA_V3_SECRET_KEY;

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

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

  // POST データの安全性をチェック(サニタイズ)
  $_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'] ?? '';

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

      // 宛先メールアドレスと宛名
      $mailTo = MAIL_TO_NAME . " <" . MAIL_TO . ">";

      // ヘッダー配列を用意
      $headers = [];
      $headers[] = 'Content-Type: text/plain; charset=UTF-8';
      $headers[] = 'From: ' . MAIL_FROM_NAME . ' <' . MAIL_FROM . '>';
      $headers[] = 'Reply-To: ' . $uname . ' <' . $email . '>';

      if (defined('MAIL_CC_NAME') && defined('MAIL_CC') && MAIL_CC !== '') {
        $headers[] = 'Cc: ' . MAIL_CC_NAME . ' <' . MAIL_CC . '>';
      }
      if (defined('MAIL_BCC') && MAIL_BCC !== '') {
        $headers[] = 'Bcc: <' . MAIL_BCC . '>';
      }

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

      // メール送信
      $result = wp_mail($mailTo, $subject, $mail_body, $headers);

      // 自動返信メール送信結果の初期化
      $reply_result = false;

      // 自動返信メール(オプション)
      if ($result) {
        if (defined('AUTO_REPLY_ENABLED') && AUTO_REPLY_ENABLED && defined('AUTO_REPLY_NAME')) {

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

          $reply_body = <<<EOT
{$uname} 様

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

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

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

<お問い合わせ内容>
{$body}
EOT;

          // ヘッダーを配列で用意
          $reply_headers = [
            'Content-Type: text/plain; charset=UTF-8',
            'From: ' . AUTO_REPLY_NAME . ' <' . MAIL_FROM . '>',
            'Reply-To: ' . AUTO_REPLY_NAME . ' <' . MAIL_TO . '>',
          ];

          // 自動返信メール送信
          $reply_result = wp_mail($email, $reply_subject, $reply_body, $reply_headers);
        }
      } 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;
  }
?>
  <div class="contact complete">
    <h3 aria-label="送信完了メッセージ">送信が完了しました。</h3>
    <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>
<?php

  return ob_get_clean();
});

スタイル CSS

この例では assets/css/ に contact-form-style.css という CSS を作成して読み込んでいます。

環境に合わせて適宜変更します。

/**
*
*  contact form pages
*
**/

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

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

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

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

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

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

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

@media screen and (max-width: 480px) {
  .contact {
    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;
}

form.confirm button {
	vertical-align: middle;
}
.confirm-forms {
  display: flex;
  flex-wrap: wrap;
  gap: 20px;
}

.contact .error-php,
.contact .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: 18px;
}

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

.menu-item {
	margin-right: 30px;
}

/* 追加(環境に応じて) */
.contact-form label {
	height: 1rem;
}

管理画面から固定ページを作成

管理画面「固定ページ > 新規追加」でそれぞれの固定ページを新規に作成します。

タイトルは任意ですが、スラッグにはそれぞれ以下を指定します。

作成する固定ページ スラッグ
入力ページ contact
確認ページ confirm
完了ページ complete
入力ページ

管理画面「固定ページ > 新規追加」で入力ページを作成します。

スラッグを contact に指定すると、自動的にテンプレートが page-contact に設定されます。

任意のタイトルを指定し、必要に応じて本文(コンテンツ)を挿入して公開します。

本文にメールアドレスを挿入する場合は、functions.php に定義してあるショートコード(例 [email]foo@example.com[/email])が使えます。

確認ページ

管理画面「固定ページ > 新規追加」で確認ページを作成します。

スラッグを confirm に指定すると、自動的にテンプレートが page-confirm に設定されます。

タイトルを指定し、必要に応じて本文(コンテンツ)を挿入して公開します。

完了ページ

管理画面「固定ページ > 新規追加」で完了ページを作成します。

スラッグを complete に指定すると、自動的にテンプレートが page-complete に設定されます。

タイトルを指定し、必要に応じて本文(コンテンツ)を挿入して公開します。完了ページでは挿入したコンテンツは送信結果の下に表示されます。必要に応じてテンプレートで位置を変更します。

動作確認

サイトの /contact/ にアクセスすると、例えば、以下のような入力ページが表示されるので、問題なくメールが送信できるかを確認します。