React

PHPmailerによるメール送信処理とMailpitで受信メール確認ができるNext.js環境をDockerで構築する方法

表題のような仕様のローカル環境構築を行う機会があったのでソースコードおよびポイントを共有いたします!

Github

完成版のソースコードです。

https://github.com/posipan/nextjs-phpmailer

動作

環境

  • Docker
  • Next.js v15.1.6(Node v20.18)
  • TypeScript v5
  • PHPmailer v6.9.3(PHP v8.3)
  • Mailpit

環境構築方法

README.mdに記載しています。

https://github.com/posipan/nextjs-phpmailer/blob/main/README.md

仕様

  • お名前、メールアドレス、お問い合わせ内容を入力するフォーム
  • 送信に成功するとユーザーと管理者メールアドレス宛にメールが届く(Mailpit環境で受信確認)

ポイント

Next.js、PHP、Mailpitの動作環境を構築する

下記はプロジェクト直下にあるdocker-compose.ymlの内容です。

Next.js、PHP、Mailpitの各環境を構築しています。

各環境のDockerfileの中身を見ればわかるのですが、Nodeはv20.18、PHPはv8.3です。

services:
  nextjs:
    build:
      context: ./nextjs
      dockerfile: Dockerfile
    ports:
      - '12000:3000'
    volumes:
      - ./nextjs:/app
      - node_modules:/app/node_modules
    depends_on:
      - php
      - mailpit
    networks:
      - mynetwork
  php:
    build:
      context: ./php
      dockerfile: Dockerfile
    ports:
      - '8081:80'
    volumes:
      - ./php:/var/www/html
    networks:
      - mynetwork
  mailpit:
    image: axllent/mailpit
    ports:
      - '8025:8025'
      - '1025:1025'
    networks:
      - mynetwork
volumes:
  node_modules:

networks:
  mynetwork:
    driver: bridge

セキュリティ情報は.envで管理する

メールのホスト設定やお問い合わせ処理のエンドポイント等は各環境にある.envに設定します。

※.envはGitの管理対象外のため、README.mdの記載通り、同階層の.env.exampleをコピーして.envを作成します。

記述内容は各環境の.env.exampleの値と同一です。

/nextjs/.env

APP_URL=http://localhost:12000

# お問い合わせ処理のAPIエンドポイント
NEXT_PUBLIC_FETCH_CONTACT_URL=http://localhost:8081/mail/send-mail.php

/php/mail/.env

Mailpitの仕様上、ユーザー名やパスワードは空欄でOKです。

APP_ENV=local
APP_NAME=nextjs-phpmailer
MAIL_HOST=mailpit
MAIL_SMTP_AUTH=false
MAIL_USERNAME=
MAIL_PASSWORD=
MAIL_SMTP_SECURE=tls
MAIL_PORT=1025
ADMIN_MAIL_ADDRESS=admin@example.com #メールアドレス形式であればなんでもいいです

PHPからデフォルトで.envを読み込む機能はないため、ユーザー定義関数経由で利用できるようにしています。

https://github.com/posipan/nextjs-phpmailer/blob/main/php/mail/assets/helper/helper.php

/**
 * .envファイルの読み込み
 */
if (!function_exists('loadEnv')) {
  function loadEnv($path): void
  {
    if (!file_exists($path)) {
      throw new Exception('.env file not found');
    }

    $lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
    foreach ($lines as $line) {
      if (strpos(trim($line), '#') === 0) {
        continue;
      }

      list($name, $value) = explode('=', $line, 2);
      $name = trim($name);
      $value = trim($value);

      if (!empty($name)) {
        $_ENV[$name] = $value;
      }
    }
  }
}

(省略)

フォーム部分はReact Hook Formで構築

下記は/nextjs/src/components/pages/Contact.tsxの中身です。

主にフォーム部分やお問い合わせ処理を記載しています。

フォームの状態管理はReact Hook FormというReact用のライブラリを使用して手軽に実装できます。(慣れは必要かもしれませんが…)

先の章の/nextjs/.envに設定したお問い合わせ処理のAPIエンドポイント(process.env.NEXT_PUBLIC_FETCH_CONTACT_URL)はaxios経由で利用します。

'use client';

import axios from 'axios';
import { useState } from 'react';
import { useForm, SubmitHandler } from 'react-hook-form';
import { useRouter } from 'next/navigation';
import styles from './Contact.module.css';

type FormDataTypes = {
  name: string;
  email: string;
  content: string;
};

const Contact = () => {
  const router = useRouter();

  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<FormDataTypes>({
    // defaultValues: {  // コメントアウトを外すと自動入力されます
    //   name: 'テスト太郎',
    //   email: 'example@example.com',
    //   content: 'asdf',
    // },
  });

  const [isLoading, setIsLoading] = useState<boolean>(false);

  const onSubmit: SubmitHandler<FormDataTypes> = async (
    data: FormDataTypes
  ) => {
    setIsLoading(true);

    try {
      const response = await axios.post(
        process.env.NEXT_PUBLIC_FETCH_CONTACT_URL as string,
        data,
        {
          headers: {
            Accept: 'application/json',
            'Content-Type': 'application/json',
          },
        }
      );

      const isSuccess: boolean = response.data.success;
      setIsLoading(false);

      if (isSuccess) {
        router.push('/thanks/');
      } else {
        console.log('送信に失敗しました。');
      }
    } catch (error) {
      setIsLoading(false);
      console.error(error);
    }
  };

  return (
    <form
      onSubmit={handleSubmit(onSubmit)}
      className={styles.contactForm}
    >
      <div className={styles.contactForm__item}>
        <label htmlFor="name" className={styles.contactForm__item__label}>
          お名前
        </label>
        <div className={styles.contactForm__item__body}>
          <input
            type="text"
            {...register('name', {
              required: 'お名前を入力してください',
              maxLength: {
                value: 30,
                message: '30文字以下で入力してください',
              },
            })}
            className={styles.contactForm__item__input}
            placeholder="山田 太郎"
          />
          {errors.name?.message && (
            <p className={styles.contactForm__error}>{errors.name?.message}</p>
          )}
        </div>
      </div>
      <div className={styles.contactForm__item}>
        <label htmlFor="email" className={styles.contactForm__item__label}>
          メールアドレス
        </label>
        <div className={styles.contactForm__item__body}>
          <input
            type="email"
            {...register('email', {
              required: 'メールアドレスを入力してください',
              maxLength: {
                value: 255,
                message: '255文字以下で入力してください',
              },
              pattern: {
                value:
                  /^[a-zA-Z0-9_.+-]+@([a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]*\.)+[a-zA-Z]{2,}$/,
                message: 'メールアドレス形式で入力してください',
              },
            })}
            className={styles.contactForm__item__input}
            placeholder="example@example.com"
          />
          {errors.email?.message && (
            <p className={styles.contactForm__error}>{errors.email?.message}</p>
          )}
        </div>
      </div>
      <div className={styles.contactForm__item}>
        <label htmlFor="content" className={styles.contactForm__item__label}>
          お問い合わせ内容
        </label>
        <div className={styles.contactForm__item__body}>
          <textarea
            {...register('content', {
              required: 'お問い合わせ内容を入力してください',
              maxLength: {
                value: 1000,
                message: '1000文字以下の数字で入力してください',
              },
            })}
            className={styles.contactForm__item__textarea}
            rows={10}
          />
          {errors.content?.message && (
            <p className={styles.contactForm__error}>
              {errors.content?.message}
            </p>
          )}
        </div>
      </div>
      <button type="submit" className={styles.contactForm__btn}>
        {isLoading ? '送信中...' : '送信する'}
      </button>
    </form>
  );
};

export default Contact;

メール送信処理はPHPで構築し、APIエンドポイント化

今回PHPmailerを使ったお問い合わせ環境ということなので、PHPで構築していきます。

/php/mail/send-mail.phpにメール送信処理を書いており、このファイルをお問い合わせ処理APIエンドポイント(http://localhost:8081/mail/send-mail.php)として利用します。

<?php
require_once '../assets/helper/helper.php';

require_once '../assets/libs/phpmailer/src/PHPMailer.php';
require_once '../assets/libs/phpmailer/src/SMTP.php';
require_once '../assets/libs/phpmailer/src/Exception.php';

loadEnv(__DIR__ . '/../.env'); // /php/.envの読み込み

use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;

header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: POST");
header("Access-Control-Allow-Headers: Content-Type");
header("Content-Type: application/json");

if ($_SERVER["REQUEST_METHOD"] !== 'POST') {
  echo json_encode([
    "success" => false,
  ]);
  exit;
}

$data = json_decode(file_get_contents("php://input"), true);

// ユーザーからの入力を取得
$name = isset($data['name']) ? sanitize($data['name']) : '';
$email = isset($data['email']) ? sanitize($data['email']) : '';
$content = isset($data['content']) ? sanitize($data['content']) : '';

// バリデーションチェック(簡易)
if (
  empty($name) ||
  empty($email) ||
  empty($content)
) {
  echo json_encode([
    "success" => false,
  ]);
  exit;
}

/**
 * メール送信処理
 */
$mail = new PHPMailer(true);

$appEnv = $_ENV['APP_ENV'];

try {
  $mail->isSMTP();
  $mail->Host = $_ENV['MAIL_HOST'];
  $mail->SMTPAuth = $appEnv === 'local' ? false : true;
  $mail->Username = $appEnv === 'local' ? '' : $_ENV['MAIL_USERNAME'];
  $mail->Password = $appEnv === 'local' ? '' : $_ENV['MAIL_PASSWORD'];
  $mail->SMTPSecure = $appEnv === 'local' ? '' : $_ENV['MAIL_SMTP_SECURE'];
  $mail->Port = $_ENV['MAIL_PORT'];
  $mail->CharSet = 'UTF-8';
  $mail->isHTML();

  $appName = $_ENV['APP_NAME'];
  $adminEmail = $_ENV['ADMIN_MAIL_ADDRESS'];

  $post = [
    'name' => $name,
    'email' => $email,
    'content' => $content,
  ];

  /**
   * ユーザー宛
   */
  $mail->setFrom($adminEmail, $appName);
  $mail->addAddress($email, $name); // 宛先
  $mail->Subject = "【{$appName}】お問い合わせいただきありがとうございました"; // 件名
  $lead = "{$name}様<br><br>お問い合わせいただきありがとうございました。\n以下の内容でお問い合わせをお受けしました。<br><br>";
  $body = $lead . set_mail_content($post);
  $mail->Body = nl2br($body);
  $mail->send();

  /**
   * 管理者宛
   */
  $mail->clearAddresses(); // 宛先をクリア
  $mail->addAddress($adminEmail, $appName);
  $mail->Subject = "ホームページからお問い合わせがありました";
  $lead = '以下の内容でホームページからお問い合わせがありました。<br><br>';
  $body = $lead . set_mail_content($post);
  $mail->Body = nl2br($body);

  $mail->send();

  echo json_encode([
    "success" => true,
  ]);
} catch (Exception $e) {
  echo json_encode([
    "success" => false,
  ]);
  exit;
}

デプロイする時は…

静的サイトとしてビルドしてデプロイすることが前提となりますが、ビルドを実行する前にNext.jsとPHPの各.env定義値をアップロード先やメールサーバーの設定に合わせてください。

また、お問い合わせ処理定義している箇所はNext.js環境ではないため、ビルド後にAPIエンドポイントに合わせて、mailフォルダごと任意のディレクトリにアップロードする必要があります。

今回のソースコードのような仕様でウェブサイトを公開する場合、おそらくNext.js環境とPHP環境でドメインが異なるということはないと思うので、以下のような状態になると想定しています。(ドメイン名はサンプルです)

/nextjs/.env

APP_URL=https://example.com

# お問い合わせ処理のAPIエンドポイント
NEXT_PUBLIC_FETCH_CONTACT_URL=https://example.com/mail/send-mail.php

/php/.env

APP_ENV=production # local以外で設定してください。
APP_NAME=[サイト名を入力してください]
MAIL_HOST=[ホストを入力してください]
MAIL_SMTP_AUTH=true
MAIL_USERNAME=[ユーザー名を入力してください]
MAIL_PASSWORD=[パスワードを入力してください]
MAIL_SMTP_SECURE=[メールサーバーの環境に応じて、tlsまたはsslを入力してください]
MAIL_PORT=[メールサーバーの環境に応じて、ポート番号を入力してください]
ADMIN_MAIL_ADDRESS=[有効なメールアドレスを入力してください]

おわりに

良きNext.jsライフを🖐️