RemixのSPAモードでお問い合わせフォーム
SPAでお問い合わせフォームを作ってみる。当然必要なのはメールを送信する機能だが、サーバサイドの機能は使えないのでどうするものかと考える。
最近ではSendGridを使ってメール送信したりすることが多いようだ。でもクライアントサイドのみで動かしているので、送信時にAPIキーが漏れてしまうので流石にまずそう。
さくらインターネットの共有サーバを使っているので、メール送信部分だけPHPで実装することにした。
Conformでフォームを作る
バリデーションは最近流行ってそうなConformというライブラリを使ってみることにした。スキーマ定義はZodを使ってみる。Yupは使ったことはあったのだけど、最近こちらも人気らしい。
お問い合わせフォームということで、名前、メールアドレス、本文の3項目のみ。スキーマ定義は最低限で下記のような感じにしてみた。
const schema = z.object({
name: z
.string({ required_error: 'お名前が入力されていません。' })
.max(100, 'お名前は100文字以内で入力してください。'),
email: z
.string({ required_error: 'メールアドレスが入力されていません。' })
.email('メールアドレスの形式が正しくありません。'),
message: z
.string({ required_error: '本文が入力されていません。' })
.max(10000, '本文は10000文字以内で入力してください。'),
});
あとはuseFormフックを利用してFormと各項目に設定するだけだ。
const lastResult = useActionData<typeof clientAction>();
const [form, { name, email, message }] = useForm({
lastResult,
onValidate({ formData }) {
return parseWithZod(formData, { schema });
},
});
<Form method='post' {...getFormProps(form)}>
<div>{form.errors}</div>
<div>
<label htmlFor='contact-name'>
お名前
</label>
<input
{...getInputProps(name, { type: 'text' })}
id='contact-name'
placeholder='お名前'
/>
<div>{name.errors}</div>
</div>
</div>
...以下略
PHPにフォームデータを送信
続いてメール送信とサーバサイドバリデーション。RemixのActionを使うことでサーバサイドの処理も簡単におこなうことができる。しかし、今回はサーバサイドの処理は使えないので、PHPにfetchでフォームのデータを送信し、受け取ったデータを処理するという流れになる。
export async function clientAction({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const submission = parseWithZod(formData, { schema });
if (submission.status !== 'success') {
return submission.reply();
}
const token = await window.grecaptcha.execute(import.meta.env.VITE_RECAPTCHA_SITE_KEY, { action: 'contact' });
formData.append('g-recaptcha-response', token);
const json = Object.fromEntries(formData);
const response = await fetch(import.meta.env.VITE_CONTACT_URL, {
method: 'POST',
body: JSON.stringify(json),
});
const responseJson: ContactResponse = await response.json();
if (!response.ok) {
return submission.reply({ formErrors: [...(responseJson.message ?? [])], fieldErrors: responseJson.errors });
}
return submission.reply();
}
ちなみにスパム対策も兼ねてreCAPTCHAもしれっと入れている。蛇足だが、SPAで環境変数を受け渡すには、頭にVITE_を付けて設定する必要があるらしい。そうすれば、import.meta.envから取り出せる。
下記のようにformData()でフォームのデータを取り出せるが、これをJsonにしてPHPにPOSTするだけ。clientActionでparseWithZodしているが、これはいらないかもしれない。
const formData = await request.formData();
const json = Object.fromEntries(formData);
const response = await fetch(import.meta.env.VITE_CONTACT_URL, {
method: 'POST',
body: JSON.stringify(json),
});
PHPでリクエストデータを検証する
TypeScriptとPHPが混在してなにやら変な感じになってしまうが仕方ない。
reCAPTCHAを使ってはいるが、最低限のPOSTデータかの確認や入力されたデータのバリデーションはしたいところ。$errorsの出力をConformで扱えるものと合わせることで、そのままサーバサイドのエラーとして使える。
// リクエストメソッドの確認
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
exit(json_encode(['status' => STATUS_NG, 'message' => 'Only POST requests are allowed.']));
}
// 文字数チェック
if ((!is_string($data['name']) || mb_strlen($data['name']) > 100)) {
$errors['name'] = ["${fields['name']}は100文字以内で入力してください。"];
}
// Zodのemail正規表現と合わせる
$pattern = '/^(?!\.)(?!.*\.\.)([A-Z0-9\'+\-\.]*)[A-Z0-9_+\-]@([A-Z0-9][A-Z0-9\-]*\.)+[A-Z]{2,}$/i';
if ((!is_string($data['email']) || preg_match($pattern, $data['email']) === 0)) {
$errors['email'] = ["${fields['email']}の形式が正しくありません。"];
}
reCAPTCHAv3の検証処理して、スコアが0.5以下はBOTや不正なユーザーとして弾く。reCAPTCHAのシークレットキーは直接ファイルに書きたくなかったので、htaccessにSetEnvで環境変数を設定することで対応した。さくらインターネットではSetEnvで設定できないかと思ったが、一応設定できて$_SERVERから取り出すことができた。
SetEnv RECAPTCHA_SECRET_KEY xxxx
$payload = http_build_query([
'secret' => $_ENV['RECAPTCHA_SECRET_KEY'] ?: $_SERVER['RECAPTCHA_SECRET_KEY'],
'response' => $token
]);
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, 'https://www.google.com/recaptcha/api/siteverify');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
$result = json_decode($response);
if ($result === null || !$result->success) {
return false;
}
// スコア判定
if ($result->score < 0.5) {
return false;
}
PHPでメール送信する
PHPでのメール送信は、mb_send_mailを使うと日本語でも楽に対応できる。最近はUTF-8でもほとんど問題なさそうなのでUTF-8で送る。文字化けが起こりそうなヘッダーは「Content-Transfer-Encoding」と「Content-Type」あたり。
「Content-Transfer-Encoding」は7bit、8bitやらbase64など色々設定できるが、UTF-8は8bit単位の文字なので変換せずにそのまま送信するなら8bitにしておけば問題なさそう。だだ一部8bitに対応していないSMTPサーバがあるかもしれないらしいとかなんとか。
念のためにbase64にして送ることにした。mb_send_mailを使っているので、base64でエンコードする必要はなくそのまま渡せば勝手に変換してくれる。
$headers = [
'MIME-Version' => '1.0',
'Content-Transfer-Encoding' => 'base64',
'Content-Type' => 'text/plain; charset=UTF-8'
];
mb_send_mail(
$email,
$subject,
$body,
$headers
);
あとは結果をRemix側で受け取り、結果を返すだけで完了。
const responseJson: ContactResponse = await response.json();
if (!response.ok) {
return submission.reply({ formErrors: [...(responseJson.message ?? [])], fieldErrors: responseJson.errors });
}
return submission.reply();
https://github.com/pontago/greenstudio-web/blob/develop/app/routes/contact.tsx
https://github.com/pontago/greenstudio-web/blob/develop/backend/app/contact.php