LoginSignup
0
2

Cloudflare PagesとFunctions、D1、blastengine、Next.jsを使ってお問い合わせフォームを作成する

Posted at

Cloudflareと聞くとCDNのイメージが強いですが、今は静的ホスティングのPagesやエッジで関数を実行できるFunctionsなど、フロントエンド開発に役立つサービスを多数リリースしています。

今回はそんなCloudflareの各種サービスとblastengineを使ってお問い合わせフォームを実装します。

コード

今回のコードはblastengineMania/cloudflare-nextjs-contactにアップしてあります。ライセンスはMIT Licenseです。

アーキテクチャ

今回のアーキテクチャは以下のようになります。入力フォームは、Next.jsを使って表示します。送信時にはPages Functionsを呼び出します。Functionsではblastengineを使ったメール送信に加えて、入力内容をD1(SQLite3)に保存しています。

Untitled.png

Cloudflare Pagesの準備

まずCloudflare Pagesの準備です。とはいっても、CLIを使って対話的に進められます。

$ npm create cloudflare

今回はオプションを以下のように選択しています。

  • dir
    blastengine
  • アプリケーションの種類
    Website or web app
  • フレームワーク
    Next
  • TypeScriptの利用
  • ESLintの利用
  • Tailwind CSSは不使用
  • srcディレクトリの利用
  • App Routerの利用
  • import aliasの不使用
  • next-on-pages の利用

実装について

Cloudflare Pagesの実装

Cloudflare Pages向けは素のNext.jsを利用しています。お問い合わせフォームの入力項目は以下の通りです。

項目 変数名(HTML上)
会社名 テキスト company
名前 テキスト name
メールアドレス テキスト email
問い合わせ種別 選択 type
問い合わせ内容 テキスト message

フォーム

上記項目を以下のように配置しています。

// フォーム入力データの型
type formData = {
  company: string;
  name: string;
  email: string;
  type: string;
  message: string;
};

const defaultValue: formData = {
	company: '',
	name: '',
	email: '',
	type: '',
	message: '',
};

const options: string[] = ['会社について', 'サービスについて', 'その他'];
const [form, setForm] = useState(defaultValue);

return (
	<>
		<div className={styles.container}>
			<Head>
				<title>お問い合わせ</title>
			</Head>

			<main className={styles.main}>
				<h3 className={styles.title}>お問い合わせ</h3>
				<form className="container" onSubmit={send}>
					<div className="company block">
						<label htmlFor="frm-company">会社名</label>
						<input
							id="frm-company"
							type="text"
							value={form.company}
							autoComplete="company"
							onChange={(e) =>
								setForm({ ...form, ...{ company: e.target.value } })
							}
							required
						/>
					</div>
					<div className="account block">
						<label htmlFor="frm-name">名前</label>
						<input
							id="frm-name"
							type="text"
							value={form.name}
							autoComplete="name"
							onChange={(e) =>
								setForm({ ...form, ...{ name: e.target.value } })
							}
							required
						/>
					</div>
					<div className="email block">
						<label htmlFor="frm-email">メールアドレス</label>
						<input
							id="frm-email"
							type="email"
							value={form.email}
							autoComplete="email"
							onChange={(e) =>
								setForm({ ...form, ...{ email: e.target.value } })
							}
							required
						/>
					</div>
					<div className="block type">
						<label htmlFor="frm-type">お問い合わせ種別</label>
						<select
							onChange={(e) =>
								setForm({ ...form, ...{ type: e.target.value } })
							}
						>
							{options.map((option) => (
								<option value={option} key={option}>{option}</option>
							))}
						</select>
					</div>
					<div className="message block">
						<label htmlFor="frm-message">Message</label>
						<textarea
							id="frm-message"
							rows={6}
							value={form.message}
							onChange={(e) =>
								setForm({ ...form, ...{ message: e.target.value } })
							}
						>
						</textarea>
					</div>
					<button onClick={send}>送信</button>
				</form>
			</main>
		</div>
	</>
);

Untitled.jpeg

CSSは以下のようになっています。

html,
body {
  padding: 0;
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
    Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
  background: #1e1e1e;
  min-height: 100vh;
  display: flex;
  color: rgb(243, 241, 239);
  justify-content: center;
  align-items: center;
}

.block {
  display: flex;
  flex-direction: column;
}

.name {
  display: flex;
  flex-direction: row;
  justify-content: space-between;
}

.container {
  font-size: 1.3rem;
  border-radius: 10px;
  width: 85%;
  padding: 50px;
  box-shadow: 0 54px 55px rgb(78 78 78 / 25%), 0 -12px 30px rgb(78 78 78 / 25%),
    0 4px 6px rgb(78 78 78 / 25%), 0 12px 13px rgb(78 78 78 / 25%),
    0 -3px 5px rgb(78 78 78 / 25%);
}

.container input {
  font-size: 1.2rem;
  margin: 10px 0 10px 0px;
  border-color: rgb(31, 28, 28);
  padding: 10px;
  border-radius: 5px;
  background-color: #e8f0fe;
}

.container select {
  font-size: 1.2rem;
  margin: 10px 0 10px 0px;
  border-color: rgb(31, 28, 28);
  padding: 10px;
  border-radius: 5px;
  background-color: #e8f0fe;
}

.container textarea {
  margin: 10px 0 10px 0px;
  padding: 5px;
  border-color: rgb(31, 28, 28);
  border-radius: 5px;
  background-color: #e8f0fe;
  font-size: 20px;
}

.container h1 {
  text-align: center;
  font-weight: 600;
}

.name div {
  display: flex;
  flex-direction: column;
}

.block button {
  padding: 10px;
  font-size: 20px;
  width: 30%;
  border: 3px solid black;
  border-radius: 5px;
}

.button {
  display: flex;
  align-items: center;
}

textarea {
  resize: none;
}

送信時の処理

送信時にはPages Functionsを呼び出しています。パスは POST /functions/sendmail としています。送信するデータはフォーム入力された内容そのままです。

type functionReponse = {
  delivery_id?: number;
};

// フォームの送信処理
const send = async (e: any) => {
	e.preventDefault();
	// Cloudflare Workerへ送信
	const res = await fetch('/functions/sendmail', {
		method: 'POST',
		headers: {
			'Content-Type': 'application/json',
		},
		body: JSON.stringify(form),
	});
	// レスポンスを受け取る
	const json = await res.json() as functionReponse;
	// レスポンスにdelivery_idが含まれていれば送信成功
	if (json.delivery_id) {
		alert('送信しました');
		setForm(defaultValue);
	}
};

D1の準備

D1はPages FunctionsやWorkerで使えるデータベースです。SQLite3相当になります。今回は be_customers として作成しています。

$ npx wrangler d1 create be_customers

作成したら、管理画面で紐付けを行います。

そして、初期スキーマを作成します。内容は以下の通りで、 shema.sql というファイルにしています。

DROP TABLE IF EXISTS Contacts;
CREATE TABLE Contacts (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    company VARCHAR(255) NOT NULL,
    accountname VARCHAR(255) NOT NULL,
    email VARCHAR(255) NOT NULL,
    type VARCHAR(255) NOT NULL,
    message TEXT NOT NULL,
    delivery_id INTEGER
);

この内容を実行します。 --local でローカルの開発環境に、何も付けなければCloudflare上のD1になります。

$ npx wrangler d1 execute be_customers --file=./schema.sql --local
$ npx wrangler d1 execute be_customers --file=./schema.sql

D1作成時に表示されるIDなどを wrangler.toml に記載します。おそらくNext.jsプロジェクトを作った時点ではこのファイルは存在しないので、新規作成します。 binding は環境変数名になります。 preview_database_id はローカル開発時に必要なIDになります。

[[d1_databases]]
binding = "DB"
database_name = "be_customers"
database_id = "999999-9999-9999-9999-999999999"
preview_database_id = "DB"

Pages Functionsの実装

Pages Functionsの実装です。これは src/app/functions 以下に作成します。つまりファイルは src/app/functions/sendmail/route.ts になります。

blastengine SDKは使えません

Pages FunctionsはNode.jsではないので、blastengineのSDKは使えません。そのため、blastengineのAPIをfetch関数で呼び出す形になっています。

.dev.varsファイルの作成

プロジェクトルートに .dev.vars ファイルを作成します。環境変数は他にも渡し方があるようですが、wrangler.tomlはGitリポジトリに入れたかったので、この方法を選択しました。

BLASTENGINE_USER_ID=blastengineのユーザーID
BLASTENGINE_API_KEY=blastengineのAPIキー

ファンクションの内容

ファンクションの基本形は以下のようになります。

// Cloudflare Workers および Next.js に関連するモジュールをインポート
import { D1Database } from '@cloudflare/workers-types';
import type { NextRequest } from 'next/server'

// エッジサーバーでの実行を指定
export const runtime = 'edge';

// 環境変数からデータベースの情報を取得
const DB = process.env.DB as unknown as D1Database;

// フォームから受け取る入力データの型を定義
type formData = {
  company: string;
  accountname: string;
  email: string;
  type: string;
  message: string;
};

// メール送信APIからの応答の型を定義
type blastengineResponse = {
  delivery_id: number;
};

// POSTリクエストの処理関数
export async function POST(request: NextRequest) {
  // リクエストボディから入力データを取得
  const body = await request.json() as formData;

  // メールを送信し、その結果を取得
  const json = await sendMail(body);

  // データベースに問い合わせ情報を保存
  await saveContact(body, json.delivery_id);

  // 応答を返す
  return new Response(JSON.stringify(json));
};

メール送信処理

メール送信処理は sendMail 関数で実装しています。blastengineのユーザーIDとAPIキーは環境変数から取得します。そして generateToken 関数で認証トークンを生成します(関数は後述)。後は本文や送信元の情報を組み立てて、blastengineのAPIを呼び出します。

送信がうまくいった場合には配信ID(delivery_id)が返ってきます。

// メールを送信する関数
const sendMail = async (body: formData): Promise<blastengineResponse> => {
  // 環境変数からAPIの認証情報を取得
  const apiUser = process.env.BLASTENGINE_USER_ID;
  const apiKey = process.env.BLASTENGINE_API_KEY;

  // 認証トークンを生成
  const token = await generateToken(`${apiUser}${apiKey}`);

  // リクエストヘッダーを設定
  const headers = {
    Authorization: `Bearer ${token}`,
    'Content-Type': 'application/json',
  };

  // メールの内容を設定
  const text_part = `__ACCOUNTNAME__様
    お問い合わせいただきありがとうございます。内容を確認し、追ってご連絡いたします。

    会社名:
    __COMPANY__
    お名前:
    __ACCOUNTNAME__
    お問い合わせ内容:
    __MESSAGE__
    `;

  // メールのデータを整形
  const data = {
    from: {
      email: 'no-reply@blastengine.jp',
      name: '管理者'
    },
    to: body.email,
    cc: ['atsushi@moongift.co.jp'],
    subject: 'お問い合わせありがとうございます',
    encode: 'UTF-8',
    text_part,
    // メールテンプレート内のプレースホルダーに対応する値を設定
    insert_code: Object.entries(body).map(([key, value]) => ({
      key: `__${key.toUpperCase()}__`,
      value,
    })),
  };

  // APIを呼び出してメールを送信
  const res = await fetch('https://app.engn.jp/api/v1/deliveries/transaction', {
    method: 'POST',
    headers,
    body: JSON.stringify(data),
  });

  // APIの応答をJSONとして解析
  const json = await res.json();
  return json as blastengineResponse;
}

フォームで入力された値(たとえば company=会社名)はメール本文の中で __COMPANY__ といったプレースホルダーに置き換えて使えるようにしています( insert_code のところ)。この insert_code で指定するキーは4文字以下は使えないので注意してください(たとえば name は使えません)。

generateToken 関数は以下のようになります。SHA256でハッシュ化し、それをBase64で文字列にします。実装は【テックコラム】JavaScript Web Crypto API で SHA256 ハッシュ値を作る | DataCurrentを参考にさせてもらっています。

// 認証トークンを生成する関数
const generateToken = async (message: string): Promise<string> => {
  // 文字列をUint8Arrayに変換
  const msgUint8 = new TextEncoder().encode(message);

  // SHA-256ハッシュを計算
  const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8);
  const hashArray = Array.from(new Uint8Array(hashBuffer));

  // ハッシュを16進数の文字列に変換
  const hashHex = hashArray
    .map(b => b.toString(16).padStart(2, '0'))
    .join('');

  // ハッシュをBase64形式のトークンに変換
  const token = Buffer
    .from(hashHex.toLowerCase())
    .toString('base64');

  return token;
};

データベース保存処理

データベース(D1)への保存処理は以下のようになります。 DB という変数がありますので、それを使ってINSERT文を実行します。 bind メソッドを使えば、 ? で与えた部分に文字列を入れてくれます。

// データベースに問い合わせ情報を保存する関数
const saveContact = async (body: formData, deliveryId: number): Promise<boolean> => {
  // SQLを使用してデータをデータベースに挿入
  const { success } = await DB
    .prepare(`INSERT INTO Contacts (
      company,
      accountname,
      email,
      type,
      message,
      delivery_id
    ) VALUES (?, ?, ?, ?, ?, ?)`)
    .bind(body.company, body.accountname, body.email, body.type, body.message, deliveryId)
    .run();
  // 操作が成功したかどうかを返す
  return success;
};

全体のコード

Pages Functionsの全体のコードは以下のようになります。

// Cloudflare Workers および Next.js に関連するモジュールをインポート
import { D1Database } from '@cloudflare/workers-types';
import type { NextRequest } from 'next/server'

// エッジサーバーでの実行を指定
export const runtime = 'edge';

// 環境変数からデータベースの情報を取得
const DB = process.env.DB as unknown as D1Database;

// フォームから受け取る入力データの型を定義
type formData = {
  company: string;
  accountname: string;
  email: string;
  type: string;
  message: string;
};

// メール送信APIからの応答の型を定義
type blastengineResponse = {
  delivery_id: number;
};

// POSTリクエストの処理関数
export async function POST(request: NextRequest) {
  // リクエストボディから入力データを取得
  const body = await request.json() as formData;

  // メールを送信し、その結果を取得
  const json = await sendMail(body);

  // データベースに問い合わせ情報を保存
  await saveContact(body, json.delivery_id);

  // 応答を返す
  return new Response(JSON.stringify(json));
};

// データベースに問い合わせ情報を保存する関数
const saveContact = async (body: formData, deliveryId: number): Promise<boolean> => {
  // SQLを使用してデータをデータベースに挿入
  const { success } = await DB
    .prepare(`INSERT INTO Contacts (
      company,
      accountname,
      email,
      type,
      message,
      delivery_id
    ) VALUES (?, ?, ?, ?, ?, ?)`)
    .bind(body.company, body.accountname, body.email, body.type, body.message, deliveryId)
    .run();
  // 操作が成功したかどうかを返す
  return success;
};

// メールを送信する関数
const sendMail = async (body: formData): Promise<blastengineResponse> => {
  // 環境変数からAPIの認証情報を取得
  const apiUser = process.env.BLASTENGINE_USER_ID;
  const apiKey = process.env.BLASTENGINE_API_KEY;

  // 認証トークンを生成
  const token = await generateToken(`${apiUser}${apiKey}`);

  // リクエストヘッダーを設定
  const headers = {
    Authorization: `Bearer ${token}`,
    'Content-Type': 'application/json',
  };

  // メールの内容を設定
  const text_part = `__ACCOUNTNAME__様
    お問い合わせいただきありがとうございます。内容を確認し、追ってご連絡いたします。

    会社名:
    __COMPANY__
    お名前:
    __ACCOUNTNAME__
    お問い合わせ内容:
    __MESSAGE__
    `;

  // メールのデータを整形
  const data = {
    from: {
      email: 'no-reply@blastengine.jp',
      name: '管理者'
    },
    to: body.email,
    cc: ['atsushi@moongift.co.jp'],
    subject: 'お問い合わせありがとうございます',
    encode: 'UTF-8',
    text_part,
    // メールテンプレート内のプレースホルダーに対応する値を設定
    insert_code: Object.entries(body).map(([key, value]) => ({
      key: `__${key.toUpperCase()}__`,
      value,
    })),
  };

  // APIを呼び出してメールを送信
  const res = await fetch('https://app.engn.jp/api/v1/deliveries/transaction', {
    method: 'POST',
    headers,
    body: JSON.stringify(data),
  });

  // APIの応答をJSONとして解析
  const json = await res.json();
  return json as blastengineResponse;
}

// 認証トークンを生成する関数
const generateToken = async (message: string): Promise<string> => {
  // 文字列をUint8Arrayに変換
  const msgUint8 = new TextEncoder().encode(message);

  // SHA-256ハッシュを計算
  const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8);
  const hashArray = Array.from(new Uint8Array(hashBuffer));

  // ハッシュを16進数の文字列に変換
  const hashHex = hashArray
    .map(b => b.toString(16).padStart(2, '0'))
    .join('');

  // ハッシュをBase64形式のトークンに変換
  const token = Buffer
    .from(hashHex.toLowerCase())
    .toString('base64');

  return token;
};

まとめ

今回はCloudflare PagesとFunctions、D1、blastengine、Next.jsを使ってお問い合わせフォームを作成しました。ホスティングからFaaSまでCloudflare上で完結できるのが便利です。次回はこのD1にあるデータを取得し、一括配信メールを使ってメール送信を行うデモを紹介します。

エンジニア向けメール配信システム「ブラストエンジン(blastengine)」

0
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
2