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

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

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

## コード

今回のコードは[blastengineMania/cloudflare\-nextjs\-contact](https://github.com/blastengineMania/cloudflare-nextjs-contact)にアップしてあります。ライセンスはMIT Licenseです。

## アーキテクチャ

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

![Untitled.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/3860fa24-aa70-a6c0-ca0c-57feefba1103.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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/c8d0d5a3-1b71-88c1-4f25-2cdd8ffa19be.jpeg)


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

```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` としています。送信するデータはフォーム入力された内容そのままです。

```tsx
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` というファイルにしています。

```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になります。

```toml
[[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キー
```

### ファンクションの内容

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

```tsx
// 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）が返ってきます。

```tsx
// メールを送信する関数
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](https://www.datacurrent.co.jp/column/jssha256-20211222/)を参考にさせてもらっています。

```tsx
// 認証トークンを生成する関数
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` メソッドを使えば、 `?` で与えた部分に文字列を入れてくれます。

```tsx
// データベースに問い合わせ情報を保存する関数
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の全体のコードは以下のようになります。

```tsx
// 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）」](https://blastengine.jp/)