📮

Next.js App router + nodemailer/EmailJS + React Hook Formでお問い合わせフォーム作成

2024/06/07に公開

はじめに

Next.jsを使用して既にプロジェクトが構築されていることを前提に、お問い合わせコンポーネントを作成します。

SendGridは現在、個人アカウントからの利用を受け付けていない、EmailJSは以下の理由から本番環境での使用を見送り、最終的にはプロジェクトによって異なりますが、nodemailerとtayori(無料アカウント)を採用しました。
https://tayori.com/?gad_source=1&gclid=CjwKCAjw34qzBhBmEiwAOUQcF4jPRUtX4WF2oeqVqr1eW1xZmk5RntaJZX0-Q8nyNPDvn9yg_SudZhoCWBkQAvD_BwE

nodemailer

Varcelでデプロイした場合にGmailを用いて本番環境でメールを送る時サーバーが地理的に別の場所にあるなどの問題により、メールがブロックされる可能性があるとのことです。

私もローカルでは問題ありませんでしたが、デプロイ後に送信できませんでした。
https://zenn.dev/peishim/articles/c403e61b9898b0

https://nodemailer.com/usage/using-gmail/

追記
/api以下の動的処理は、LambdaとAPI Gatewayを使用してS3とCloudFrontでデプロイした際に問題なく動作しました!

また、メール送信時にはSlackに通知が来るようにしました。
https://zenn.dev/nenenemo/articles/8d5f7725f2bd05

本番環境での使用を見送った理由

EmailJS

クライアントサイドで動作するJavaScriptライブラリであり、APIキーやその他の構成データをクライアントサイドのコードに含める必要があります。これにより、ブラウザのNetworkタブ > PayloadからAPIキーなどが確認できてしまいます。
https://zenn.dev/y_ta/books/16910da8a3748e/viewer/891484

https://mailtrap.io/blog/nodemailer-vs-emailjs/

nodemailer

Node.jsアプリケーションから電子メールを送信するために広く使用されるモジュールで、SMTPサーバーを通じて直接メールを送信することができます。
https://nodemailer.com/

SMTPサーバー

SMTPサーバー(Simple Mail Transfer Protocol Server)は、電子メールを送受信するための標準的なプロトコルを用いたサーバーです。

アプリパスワードの作成

まずは2段階プロセスを有効にしてください。
私はアプリから2段階プロセスを有効にしました。

右上のアイコンを選択し、Googleアカウントを管理を選択してください。

セキュリティタブから2段階認証プロセスを選択して表示される手順に沿って進めてください。

2段階プロセスを有効になったら下記にアクセスしてアプリパスワードの作成してください。
https://myaccount.google.com/apppasswords

アプリ名を入力して、作成を押してください。

アプリパスワードが作成されました。スペースが入っていると思いますが、これは除いて使用してください。

Nodemailerのインストール

npm install nodemailer

typescriptの場合は、NodemailerのためのTypeScript型定義ファイルもインストールしてください。

npm install @types/nodemailer

環境変数の設定

.env.development
GMAIL_ACCOUNT=""
GMAIL_PASSWORD=""

APIルート設定

このままでは、ブラウザのNetworkタブからアプリパスワードが確認できてしまうので、APIルート設定してそこからHTTPリクエストを送るようにします。

詳しくは下記を参考にしてください。
https://zenn.dev/nenenemo/articles/59ca1b03fcf234

mkdir src/app/api/contact && touch src/app/api/contact/route.ts

今回はテンプレート内容をhtmlで記述していますが、textを使用した場合はテキストで記述することができます。

src/app/api/contact/route.ts
import { NextRequest, NextResponse } from 'next/server';
import nodemailer from 'nodemailer';

export async function POST(req: NextRequest) {
  try {
    const { data } = await req.json();

    const transporter = nodemailer.createTransport({
      host: 'smtp.gmail.com',
      port: 587,
      auth: {
        user: process.env.GMAIL_ACCOUNT,
        pass: process.env.GMAIL_PASSWORD,
      },
    });

    const mailOptions = {
      from: process.env.GMAIL_ACCOUNT,
      to: data.email,
      subject: 'お問い合わせありがとうございます',
      html: `
        <p>${data.name} 様</p>

        <p>この度は、お問い合わせいただきありがとうございます。</p>
        <p>以下の内容でお問い合わせを受け付けました。</p>

        <div style="padding: 12px; border-left: 4px solid #d0d0d0; font-style: italic;">
          <p>お問い合わせ種別:</p>
          <p>${data.type}</p>
          <p>お問い合わせ内容:</p>
          <p>${data.content}</p>
        </div>

        <p>お問い合わせいただいた内容につきまして、確認次第ご返信させていただきます。</p>
        <p>何卒よろしくお願い申し上げます。</p>
      `,
    };

    const res = await transporter.sendMail(mailOptions);

    return NextResponse.json(res, { status: 200 });
  } catch (error) {
    throw error;
  }
}

React-hook-formのインストール

https://www.react-hook-form.com/

npm install react-hook-form
src/components/contact.tsx
import axios from 'axios';
import { SubmitHandler, useForm } from 'react-hook-form';

interface FormData {
  name: string;
  email: string;
  type: string;
  content: string;
}

export function Contact() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<FormData>();

  const onSubmit: SubmitHandler<FormData> = async (data: FormData) => {
    try {
      await axios.post('/api/contact', {
        data,
      });
      alert('お問い合わせが送信されました。');
    } catch (err) {
      console.log('FAILED...', err);
      alert('送信に失敗しました。');
    }
  };

  return (
    <div style={{ maxWidth: '500px', margin: 'auto', padding: '50px' }}>
      <h2 style={{ fontSize: '24px', fontWeight: 'bold' }}>お問い合わせ</h2>
      <p>下記のフォームにご記入ください。</p>
      <form
        onSubmit={handleSubmit(onSubmit)}
        style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}
      >
        <label htmlFor='name'>お名前</label>
        <input
          id='name'
          {...register('name', { required: true })}
          type='text'
          placeholder=''
          style={{
            padding: '10px',
            fontSize: '16px',
            borderRadius: '5px',
            border: '1px solid #ccc',
          }}
        />
        {errors.name && <span style={{ color: 'red' }}>名前は必須です。</span>}

        <label htmlFor='email'>メールアドレス</label>
        <input
          id='email'
          {...register('email', { required: true,             pattern: {
            value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
            message: "invalid email address"
          } })}
          type='email'
          placeholder=''
          style={{
            padding: '10px',
            fontSize: '16px',
            borderRadius: '5px',
            border: '1px solid #ccc',
          }}
        />
        {errors.email && (
          <span style={{ color: 'red' }}>
            有効なメールアドレスを入力してください。
          </span>
        )}

        <label htmlFor='type'>お問い合わせ種別</label>
        <select
          id='type'
          {...register('type', { required: true })}
          style={{
            padding: '10px',
            fontSize: '16px',
            borderRadius: '5px',
            border: '1px solid #ccc',
          }}
        >
          <option value=''>以下から選択してください</option>
          <option value='CMS構築'>CMS構築</option>
          <option value='WEBサイト作成'>WEBサイト作成</option>
          <option value='その他'>その他</option>
        </select>
        {errors.type && (
          <span style={{ color: 'red' }}>お問い合わせの種別は必須です。</span>
        )}

        <label htmlFor='content'>お問い合わせ内容</label>
        <textarea
          id='content'
          {...register('content', { required: true })}
          style={{
            minHeight: '150px',
            padding: '10px',
            fontSize: '16px',
            borderRadius: '5px',
            border: '1px solid #ccc',
          }}
          placeholder=''
        />
        {errors.content && (
          <span style={{ color: 'red' }}>お問い合わせ内容は必須です。</span>
        )}

        <button
          type='submit'
          style={{
            width: '100%',
            padding: '10px',
            fontSize: '16px',
            borderRadius: '5px',
            backgroundColor: '#007BFF',
            color: 'white',
            border: 'none',
            cursor: 'pointer',
          }}
        >
          送信
        </button>
      </form>
    </div>
  );
}

EmailJS

EmailJSはクライアントサイドで動作し、サーバー側の設定を必要としないため、特にNext.jsのようなサーバーサイドレンダリングを行うフレームワークと組み合わせると効果的です。
https://www.emailjs.com/

無料プランでは、機能が限定的ですが、月200件のメール送信が可能なので今回はこちらを利用します。

EmailJSライブラリをインストール

必要なライブラリをインストールしてください。

npm install --save @emailjs/browser

メール設定

Add New Serviceを選択してください。

今回はGmailアカウントからメールを送信したいのでGmailを選択します。

Connect Accountで使用したいアカウントを選択し連携後、Create Serviceを押してください。

Create New Templateを選択してメールのテンプレートを作成します。

Edit ContentからCode Editorを選択してください。

<p>{{name}} 様</p>

<p>この度は、お問い合わせいただきありがとうございます。</p>
<p>以下の内容でお問い合わせを受け付けました。</p>

<div style="padding: 12px; border-left: 4px solid #d0d0d0; font-style: italic;">

<p>お問い合わせ種別:</p>
<p>{{type}}</p>
<p>お問い合わせ内容:</p>
<p>{{content}}</p>
</div>

<p>お問い合わせいただいた内容につきまして、確認次第ご返信させていただきます。</p>
<p>何卒よろしくお願い申し上げます。</p>
<br/>
<br/>
<p>※このメールはシステムから自動送信されています。</p>
<p>※返信不要のメールですが、ご不明点等がございましたらexample@gmail.comまでご連絡ください。</p>

入力してSaveしてください。

src/components/contact.tsx
import emailjs from '@emailjs/browser';
import { SubmitHandler, useForm } from 'react-hook-form';

interface FormData {
  name: string;
  email: string;
  type: string;
  content: string;
}

export function Contact() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<FormData>();

  const onSubmit: SubmitHandler<FormData> = async (data: FormData) => {
    const userID = process.env.NEXT_PUBLIC_EMAILJS_PUBLIC_KEY || '';
    const serviceID = process.env.NEXT_PUBLIC_EMAILJS_SERVICE_ID || '';
    const templateID = process.env.NEXT_PUBLIC_EMAILJS_TEMPLATE_ID || '';

    const params = {
      name: data.name,
      email: data.email,
      type: data.type,
      content: data.content,
    };

    try {
      await emailjs.send(serviceID, templateID, params, userID);
      alert('お問い合わせが送信されました。');
    } catch (err) {
      console.log('FAILED...', err);
      alert('送信に失敗しました。');
    }
  };

  return (
    <div style={{ maxWidth: '500px', margin: 'auto', padding: '50px' }}>
      <h2 style={{ fontSize: '24px', fontWeight: 'bold' }}>お問い合わせ</h2>
      <p>下記のフォームにご記入ください。</p>
      <form
        onSubmit={handleSubmit(onSubmit)}
        style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}
      >
        <label htmlFor='name'>お名前</label>
        <input
          id='name'
          {...register('name', { required: true })}
          type='text'
          placeholder=''
          style={{
            padding: '10px',
            fontSize: '16px',
            borderRadius: '5px',
            border: '1px solid #ccc',
          }}
        />
        {errors.name && <span style={{ color: 'red' }}>名前は必須です。</span>}

        <label htmlFor='email'>メールアドレス</label>
        <input
          id='email'
          {...register('email', { required: true, pattern: /^\S+@\S+$/i })}
          type='email'
          placeholder=''
          style={{
            padding: '10px',
            fontSize: '16px',
            borderRadius: '5px',
            border: '1px solid #ccc',
          }}
        />
        {errors.email && (
          <span style={{ color: 'red' }}>
            有効なメールアドレスを入力してください。
          </span>
        )}

        <label htmlFor='type'>お問い合わせ種別</label>
        <select
          id='type'
          {...register('type', { required: true })}
          style={{
            padding: '10px',
            fontSize: '16px',
            borderRadius: '5px',
            border: '1px solid #ccc',
          }}
        >
          <option value=''>以下から選択してください</option>
          <option value='CMS構築'>CMS構築</option>
          <option value='WEBサイト作成'>WEBサイト作成</option>
          <option value='その他'>その他</option>
        </select>
        {errors.type && (
          <span style={{ color: 'red' }}>お問い合わせの種別は必須です。</span>
        )}

        <label htmlFor='content'>お問い合わせ内容</label>
        <textarea
          id='content'
          {...register('content', { required: true })}
          style={{
            minHeight: '150px',
            padding: '10px',
            fontSize: '16px',
            borderRadius: '5px',
            border: '1px solid #ccc',
          }}
          placeholder=''
        />
        {errors.content && (
          <span style={{ color: 'red' }}>お問い合わせ内容は必須です。</span>
        )}

        <button
          type='submit'
          style={{
            width: '100%',
            padding: '10px',
            fontSize: '16px',
            borderRadius: '5px',
            backgroundColor: '#007BFF',
            color: 'white',
            border: 'none',
            cursor: 'pointer',
          }}
        >
          送信
        </button>
      </form>
    </div>
  );
}

下記のようにブラウザのNetworkタブ > PayloadからAPIキーなどが確認できてしまいます。

終わりに

何かありましたらお気軽にコメント等いただけると助かります。
ここまでお読みいただきありがとうございます🎉

Discussion