v1.10.0リリースノート: お問い合わせ機能の追加

この記事はNext.js版の内容です。現在はAstroで構築し直したため、情報が古い可能性があります。 当時のリポジトリは こちらあるので参考にしてください。

一方的にコンテンツを積み上げていく単方向的なアプローチから脱却すべく、重い腰をあげてお問い合わせ機能を実装した。 特にこだわりがないのであればリッチなUIを標準装備したGoogleフォームを使えば面倒な機能を実装せずに、はい終わりである。

しかし、エンジニアの個人サイトのお問い合わせフォームがGoogleフォームというのはちょっと寂しい。 せっかくなので自分が理想とするお問い合わせフォームを作ってみることにした。 一から実装するとなるとボット対策や自動返信メール、管理者私)への通知をどうするかなど意外と考えることがあって面倒だったが楽しかった。

一気に実装してしまったので思い出しながら要点だけまとめていく。

全体の処理の流れ

上記が全体の処理をまとめたシーケンス図になるのだが、レンダリングして初めて文字の小ささに気づいた。 今のところズーム機能はないため、目を凝らして見ていただく他ない。

さすがにこれでは使い物にならないので各々の処理ごとにシーケンス図を書き直した。 ただ、全体の処理を分割するとライフラインの実行仕様が把握しにくくなるので一応残してある。

エラーの箇所を省いて簡単に流れを箇条書きすると以下のようになる。

  1. ユーザがお問い合わせページにアクセスする
  2. Vercel Edge ConfigからブロックしているIPアドレスリストを取得して判定する
  3. お問い合わせページを表示する
  4. ユーザがお問い合わせフォームに入力して送信する
  5. クライアント側でreCAPTCHAトークンを取得する
  6. バックエンド側に5を含むリクエストを送信する
  7. Upstashでレートリミットを確認する
  8. reCAPTCHAにリクエストの判定を依頼する
  9. Google API OAuth 2.0を利用してアクセストークンを取得する
  10. Google Sheetsにお問い合わせ内容を記録する
  11. Google Apps Scriptが確認メールの送信をGmailにリクエストする
  12. Gmailから管理者へメールが送信される
  13. LINEから管理者へメッセージが送信される
  14. ユーザが確認メールの送信を希望する場合はResendからメールを送信する
  15. ユーザに成功メッセージを表示する
  16. お問い合わせ内容が不適切だった場合は手動でVercel Edge ConfigのブロックIPアドレスリストに登録する

IPアドレス制限

あまり考えたくないが、お問い合わせフォームを使って嫌がらせが行われる可能性は万が一にもある。 そうなってからコードを書いて対応するのは面倒くさいのであらかじめIPアドレス制限機能を追加しておく。

悪意のあるIPアドレスを毎回ビルドに組み込むのでは待ち時間が長く即効性に欠けるのでVercelのグローバルデータストアである Edge Config利用する。

以下のようにJSON形式で記述できる。

{
"blockIps": ["127.0.0.1"]
}

上記で設定した値をNext.jsのアクション実行前に読み取って適切なルーティングを行う。 具体的にはリクエスト完了前にコードを実行可能な Middleware処理を追加する。

src/middleware.ts
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { get } from '@vercel/edge-config';
export const config = { matcher: '/((?!api|_next|static|public|favicon).*)' };
export const middleware = async (request: NextRequest) => {
const blockIps = await get<string[]>('blockIps');
const accessIp = request.ip;
if (accessIp && blockIps?.includes(accessIp)) {
request.nextUrl.pathname = '/forbidden';
return NextResponse.rewrite(request.nextUrl, { status: 403 });
}
};

8行目でEdge ConfigからブロックしているIPアドレスリストを取得して、9行目でリクエスト時のIPアドレスを取得している。 リクエスト時のIPアドレスがブロック対象のIPアドレスリストに含まれる場合は/forbiddenページに飛ばす。

forbiddenページ

嫌がらせをしてくるような人にはびっくり系のブラクラを用意してあげても良いのだが、それはちょっとやりすぎなのでシンプルなページを表示するに留めた。

ちなみにEdge Configを利用してメンテナンスモードの実装も行っている(なかなか使う機会がないけど)。

入力値のバリデーション

フィールドには自由に入力させてあげたいのは山々だが、制限を設けておかないととんでもない使い方をされる可能性がある。 とはいえ、あくまでお問い合わせフォームなので特に複雑なバリデーションは施していない。

今回はスキーマ宣言とバリデーション定義を一度に行える Zod利用した。 昔は似たようなライブラリの Yup使ったこともあるが、今はスキーマファーストなバリデーションを実装したければZodを選ぶ方が無難かも1

定義したスキーマ(以下参照)はクライアントだけではなくtRPCでのサーバ側バリデーションやメール送信時の型定義まで幅広く使っている。

packages/validators/src/contact.ts
import { z } from 'zod';
export const ContactSchema = z.object({
email: z
.string()
.min(1, { message: 'メールアドレスを入力してください' })
.max(255, { message: 'メールアドレスは 255 文字以内で入力してください' })
.email({ message: 'メールアドレスの形式が正しくありません' }),
name: z
.string()
.min(1, { message: '名前を入力してください' })
.max(255, { message: '名前は 255 文字以内で入力してください' }),
type: z.enum(['jobScouting', 'projectConsultation', 'feedback', 'collaboration', 'other'], {
message: 'お問い合わせ種別を選択してください',
}),
content: z
.string()
.min(1, { message: 'お問い合わせ内容を入力してください' })
.max(2000, { message: 'お問い合わせ内容は 2000 文字以内で入力してください' }),
shouldSendReplyMail: z.boolean(),
recaptchaToken: z.string().optional(),
});

Next.jsを使っていることだし、プログレッシブエンハンスメントなフォームを実現できるServer Actionsを使おうと思ったが、shadcn/uiがreact-hook-formに依存しているため今回は見送った。 react-hook-formでもServer Actionsへの対応は進みつつあるようだが、もうしばらく時間がかかりそう2

useActionState使った実装に書き換えようと試みてはみたものの、思いの外複雑な実装になっため止めた。 shadcn/uiを使っていなかったらスムーズに導入できたと思うが、いかんせん便利すぎるので離れられない。

App Routerがリリースされて早くも1年近く経過したにもかかわらず、未だにベストプラクティスとなるバリデーションの実装方法が確立されていないので毎回悩む。 納得できる実装方法が見つかれば、Server Actionsを使うように修正する予定。

クライアント側のバリデーションが終わったらreCAPTCHAにトークンのリクエストを行う。 このトークンはバックエンド側で使うのでリクエストボディに含めておく。

reCAPTCHAトークンの取得には react-google-recaptcha-v3利用している。

レートリミット

このお問い合わせフォームは複数の外部APIを無料枠の範囲で使っているため、大量のリクエストが送信されるとすぐに無料枠をオーバーしてしまう。 それは避けたいので UpstashRedisを使ってレートリミットを実装した。

IPアドレスをキーとして1分間に1回までフォームの送信を可能にしてある。

使用方法をイメージしやすいように1つのファイルにまとめてみた。

import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
type SlidingWindowProps = Parameters<typeof Ratelimit.slidingWindow>;
const rateLimiter = (tokens: SlidingWindowProps[0], windowValue: SlidingWindowProps[1]) =>
new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(tokens, windowValue),
});
const getIpHash = async (ip: string) => {
const buffer = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(ip));
return Array.from(new Uint8Array(buffer))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
};
export const contactRouter = {
send: publicProcedure.input(ContactSchema).mutation(async ({ input, ctx }) => {
if (!ctx.ip) {
throw new TRPCError({ code: 'BAD_REQUEST' });
}
const ip = ctx.ip;
const ipHash = await getIpHash(ip);
const { success } = await rateLimiter(1, '1 m').limit(ipHash);
if (!success) {
throw new TRPCError({ code: 'TOO_MANY_REQUESTS' });
}
// ...
}),
};

Upstashには@upstash/ratelimitいう便利なライブラリがあるのでシンプルに実装が可能。 他のAPIでも利用しやすいように関数rateLimiterして汎用化している。 また、念の為IPアドレスはSHA-256でハッシュ化してからRedisに保存している。

お問い合わせフォームを送信してから1分以内に再度お問い合わせを送信すると「お問い合わせの送信回数が上限に達しました。しばらくしてから再度お試しください。」とエラーが表示される。

Botからの保護

機械的にPOSTできる窓口を設けている以上、ボット対策を考えなければならない。 レートリミットを設定しているとはいっても1分ごとに送信するみたいな設定をされたら1時間に60回リクエストを投げられてしまう(そう考えるとレートリミットのコードを公開しているのは悪手だ)。 さすがに何の利益もないのでそのようなことはあるはずもないのだが心配性なので念には念を入れておく。

Bot対策としてGoogleの reCAPTCHA Enterprise導入した。 先ほどクライアント側の処理でreCAPTCHAトークンを取得していたが、それをサーバ側で利用する。

サーバ側からreCAPTCHAにリクエストを投げると0.0 ~ 1.0の判定結果が返ってくる3 値が低くなるほどボットの可能性があるリクエストということになる。 ひとまずスコアが0.7未満であればエラーメッセージを返すようにしている。

当初はスコアを取得するためにGoogleから提供されている @google-cloud/recaptcha-enterprise利用しようとした。

前提として当サイトのtRPC経由のAPI作成にはNext.jsのEdge Runtimeを使っている。 Edge Runtimeはパフォーマンスに優れているがNode.js APIを全てサポートしていない4 そのため、パッケージ内部においてEdge RuntimeでサポートされていないAPIを利用しているとエラーが発生してしまう。

今回は不幸にもそのエラーに当たってしまった。 そうなった場合はWeb標準の fetch API使って処理を自分で書く必要がある。 あくまでもSDKはREST APIのラッパーに過ぎないため記述量は多くなるが何とかなる。 以下がその処理。

packages/api/src/services/google/recaptcha.ts
export const verifyRecaptcha = async ({
recaptchaToken,
expectedAction,
}: {
recaptchaToken: string;
expectedAction: string;
}) => {
const request = {
event: {
token: recaptchaToken,
expectedAction,
siteKey: env.RECAPTCHA_SITE_KEY,
},
} satisfies RecaptchaRequest;
const response = await fetch(
`https://recaptchaenterprise.googleapis.com/v1/projects/${env.GCP_PROJECT_ID}/assessments?key=${env.RECAPTCHA_SECRET_KEY}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(request),
},
);
if (!response.ok) {
throw new Error('Failed to verify recaptcha');
}
return (await response.json()) as RecaptchaResponse;
}

これでスコアを取得できる。

Google Sheetsにお問い合わせ内容を記録

ユーザから送信されたお問い合わせ内容はGoogle Sheets(スプレッドシート)に保存する。 RDBMSに保存しても良かったが、確認用のAPIとUIを作成しなければならず面倒だ。

Google Sheetsであればソートや絞り込みができたり、後述するGAS(Google Apps Script)と連携できるので色々とデータ活用の幅が広がる。

Node.jsからお手軽にAPIを扱える google-api-nodejs-client使って一旦実装してみたが、reCAPTCHAと同様にEdge Runtimeに起因するエラーが発生した。 面倒だが直接REST APIを叩く実装に書き換えた。

まりどころとしてGoogle Sheetsにアクセスするためには JWTトークン必要になる。 このトークンは、サービスアカウントの秘密鍵で署名する必要があったのでEdge Runtimeでも使用可能な jose使って生成した。

packages/api/src/services/google/token.ts
import { importPKCS8, SignJWT } from 'jose';
import { env } from '../../../env';
interface Token {
access_token: string;
expires_in: number;
token_type: string;
}
type TokenWithExpiration = Token & {
expires_at: number;
};
const payload = {
iss: env.GCP_CLIENT_EMAIL,
scope: 'https://www.googleapis.com/auth/spreadsheets',
aud: 'https://www.googleapis.com/oauth2/v4/token',
exp: Math.floor(Date.now() / 1000) + 60 * 60,
iat: Math.floor(Date.now() / 1000),
};
const createToken = async () => {
const privateKey = await importPKCS8(env.GCP_PRIVATE_KEY, 'RS256');
const token = await new SignJWT(payload)
.setProtectedHeader({ alg: 'RS256' })
.setIssuedAt()
.setIssuer(env.GCP_CLIENT_EMAIL)
.setAudience('https://www.googleapis.com/oauth2/v4/token')
.setExpirationTime('1h')
.sign(privateKey);
const form = {
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: token,
};
const tokenResponse = await fetch('https://www.googleapis.com/oauth2/v4/token', {
method: 'POST',
body: JSON.stringify(form),
headers: { 'Content-Type': 'application/json' },
});
if (!tokenResponse.ok) {
throw new Error('Failed to get token');
}
const json = (await tokenResponse.json()) as Token;
return {
...json,
expires_at: Math.floor(Date.now() / 1000) + json.expires_in,
};
};
let token: Promise<TokenWithExpiration> | null = null;
export const authenticate = async () => {
if (token === null) {
token = createToken();
}
let resolvedToken = await token;
if (resolvedToken.expires_at < Math.floor(Date.now() / 1000)) {
token = createToken();
resolvedToken = await token;
}
return resolvedToken;
};

これでAPIに必要な認証用トークンを取得できた。 次にGoogle Sheetsに追加するための処理を書いていく。

packages/api/src/services/google/sheets.ts
import { env } from '../../../env';
import { GoogleSheetsError } from '../../exceptions';
import { authenticate } from './token';
export const appendGoogleSheets = async ({ sheetName, values }: { sheetName: string; values: string[][] }) => {
const { access_token } = await authenticate();
const params = {
range: `${sheetName}!A2`,
insertDataOption: 'INSERT_ROWS',
valueInputOption: 'USER_ENTERED',
resource: {
values,
},
};
const response = await fetch(
`https://sheets.googleapis.com/v4/spreadsheets/${env.GOOGLE_SHEETS_ID}/values/${encodeURIComponent(params.range)}:append?valueInputOption=${params.valueInputOption}&insertDataOption=${params.insertDataOption}`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${access_token}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(params.resource),
},
);
if (!response.ok) {
throw new GoogleSheetsError('Google Sheets API request failed: ' + response.statusText);
}
console.log('Append to Google Sheets was successful');
};

Google Sheetsの1行目にはカラム名を設定するため2行目から下に追加していく。 本当は降順になるようにしたかったが、そのようなオプションはなかった。 毎回全ての行を取得して降順に成形してからAPIを投げるのはデータが多くなってきたときの処理が重そうなので止めた。

Gmailで確認メールを管理者に送信

後述するLINEにも通知メッセージを送信するようにしているが、仮にLINE APIが機能しなかった場合、お問い合わせが来ていることに気づかずビッグチャンスを逃す可能性がある。 そうならないためにもGoogle Sheetsにデータが登録されたタイミングで管理者私)にお問い合わせ内容が記載されたメールを送信されるようにした。

実装方法としてはGASを使用する。 GASにはブラウザで記述可能なエディタが付属しているけどTypeScriptが使えなかったり、モジュール化できなかったりと使いずらい。 なのでViteでトランスパイルしてから clasp使ってGASにデプロイする法をとることにした。

@google/claspインストールすれば型補完が効いて書き心地が良いので必ず入れておきたい。 実装内容は以下のとおりでシンプルにまとめてある。

packages/google-apps-script/src/services/contact.ts
import type { z } from 'zod';
import type { ContactSchema } from '@kkhys/validators';
type SendEmailParameters = Parameters<typeof GmailApp.sendEmail>;
type ContactSchemaProps = z.infer<typeof ContactSchema>;
const sheet = SpreadsheetApp.openById(import.meta.env.VITE_GOOGLE_SHEETS_ID).getActiveSheet();
const lastRow = sheet.getLastRow();
const emailColumn = 1;
const nameColumn = 2;
const typeColumn = 3;
const contentColumn = 4;
export const sendContactEmail = () => {
const recipient = '[email protected]' satisfies SendEmailParameters[0];
const subject = 'お問い合わせがありました' satisfies SendEmailParameters[1];
const body = generateBody() satisfies SendEmailParameters[2];
const options = {
// from: '[email protected]',
name: 'Keisuke Hayashi',
// htmlBody: html,
} satisfies SendEmailParameters[3];
const lock = LockService.getScriptLock();
if (lock.tryLock(1000)) {
GmailApp.sendEmail(recipient, subject, body, options);
lock.releaseLock();
}
};
const generateBody = () => {
const email = sheet.getRange(lastRow, emailColumn).getValue() as ContactSchemaProps['email'];
const name = sheet.getRange(lastRow, nameColumn).getValue() as ContactSchemaProps['name'];
const type = sheet.getRange(lastRow, typeColumn).getValue() as ContactSchemaProps['type'];
const content = sheet.getRange(lastRow, contentColumn).getValue() as ContactSchemaProps['content'];
return `kkhys.me にお問い合わせがありました。
# 名前
${name}
# メールアドレス
${email}
# お問い合わせ種別
${type}
# お問い合わせ内容
${content}
`;
};

Google SheetsとGASを連携される際の注意点として、変更をトリガーに処理を行いたい場合はLockService.getScriptLock使って排他制御を行う必要がある。 でないと、データが追加されたときに複数回トリガーが発火する可能性がある。

デプロイ後はGAS側でトリガーを設定する。 仮にGASの実行に失敗した場合でもエラー通知を送信できるので、不具合に気づかず日々過ごしていくという心配はない。

LINEで確認メッセージを管理者に送信

残念ながらメールをすぐに確認する習慣は今までの人生で身に付かなかった。 最も早く反応できるサービスを考えるとLINE一択である。 そのため、お問い合わせがあった場合はLINEに通知メッセージを送信する。

GmailはサブでLINEをメインとして運用していく。

LINEにも line-bot-sdk-nodejsいう便利なライブラリがあるが、ご多分に漏れずEdge Runtimeでは使えなかった。 そのため、今までと同様にfetch APIを使って実装した。

packages/api/src/services/line.ts
export const sendLineMessage = async ({ message }: { message: string }) => {
const response = await fetch('https://api.line.me/v2/bot/message/push', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${env.LINE_CHANNEL_ACCESS_TOKEN}`,
},
body: JSON.stringify({
to: env.LINE_USER_ID,
messages: [
{
type: 'text',
text: message,
},
],
}),
});
if (!response.ok) {
throw new LineError('Failed to send a message to LINE');
}
console.log('Successfully sent a message to LINE');
};

お問い合わせ用のお一人様チャネルを作成してそこにメッセージを送信している。 仮に送信に失敗してもGmailは送信されているため、catchブロックでエラーを捕捉してエラーログを出力するに留めている。

Resendで確認メールをユーザに送信

お問い合わせしたユーザが入力内容確認メールの返信を希望する場合は Resend使ってメールを送信している。 自動返信メールはスパムに利用される可能性もあるため、導入するか迷ったが、ユーザの利便性を考えてオプトインできるようにした。

大抵の場合、お問い合わせフォームを悪用したスパムはbotから行われるのでreCAPTCHAを実装しておけば大丈夫だろうと思っている。 人力で送信されたらIPアドレスでブロックする。 今回は実装していないが、海外からの不正なリクエストがあればお問い合わせ可能な地域を日本だけにするとか色々と考えないといけないことが多くて面白い。

閑話休題。 Resendは幸いにもEdge Runtimeにも対応していたため非常に楽に実装できた。

packages/api/src/services/email.ts
import { Resend } from 'resend';
const resend = new Resend(env.RESEND_API_KEY);
const administrator = {
name: 'Keisuke Hayashi',
};
export const sendEmail = async ({
to,
subject,
body: { html, text },
tags,
}: {
to: string;
subject: string;
body: Record<'html' | 'text', string>;
tags?: { name: string; value: string }[];
}) => {
const { data, error } = await resend.emails.send({
from: `${administrator.name} <${administrator.email}>`,
to,
subject,
html,
text,
tags,
headers: {
'X-Entity-Ref-ID': new Date().getTime().toString(),
},
});
if (error) {
throw new EmailError(`Failed to send email to ${to}. Error: ${JSON.stringify(error)}`);
}
console.log(`Successfully sent email to ${to}. Response: ${JSON.stringify(data)}`);
};

メールヘッダにX-Entity-Ref-ID付与しているのはGmailのスレッド化を防ぐためである。 お問い合わせフォームなら何度も送ることはないと思うのでなくても問題ないが。

メール本文にはHTMLとプレーンテキストの両方を設定しているマルチパートメール)。 これらを作成するのは面倒だが、react-emailいうライブラリを使うことでTSXとTailwind CSSを使いつつ楽に実装できた。 1つのソースからHTMLとプレーンテキストの両方を出力可能なのは素晴らしい。 開発サーバを立ち上げてプレビューもできる。

HTMLメール
HTMLメール
プレーンテキスト
プレーンテキストメール

そもそもユーザにもGmailを使ってメールを送信すれば良いじゃないという意見もあると思う。 しかし、Resendの管理画面の使いやすさやトラッキングできる点を考えると外部に送信するメールであればResendを使っていく方がベターだ。

最後にクライアントからユーザに成功メッセージを返せば一連の流れは完了する。 仮に自動返信メールの送信に失敗した場合は、「お問い合わせは成功したが、メールの送信には失敗した」旨を伝えるエラーメッセージを表示する。 ここでお問い合わせ自体が失敗したとエラーメッセージを表示させたらユーザが何度も送信しないといけない羽目になるので注意。

もし、不適切なお問い合わせがあった場合はIPアドレスをGoogle Sheetsに保存しているため、それをEdge Configに登録する。 そうすれば、このサイトにリクエストがあった段階で弾ける。

さいごに

気軽に実装する機能にしては重かったが、色々なAPIを使うのは楽しい。


  1. npm trendでは2023年7月ごろにZodがYupを抜き去っている参考

  2. GitHub Issue: Support Server Actions

  3. デフォルトで使用できるスコアレベルは、0.1、0.3、0.7、0.9の4つのみソース)。 全てのスコアを使いたければGoogleのセールスチームに連絡が必要。面倒なのでしていない

  4. Edge RuntimeでサポートされているAPI