はじめに
問い合わせフォームは、POSTが成功しただけでは完成ではありません。利用者にとって重要なのは、その後に次のことが分かることです。
- 問い合わせが保存されたか
- 返信をどこで確認できるか
- メールが届かなくても回答を確認できるか
- 追加のやり取りが必要か、解決済みか
最近のプロダクトでは、単発の送信フォームよりも、問い合わせ後の会話や状態を追跡できる導線が選ばれやすくなっています。一方、個人開発で大規模なチケット管理システムを構築するのは過剰です。
この記事では、次の最小構成をNext.js App RouterとPostgreSQLで実装します。
- 問い合わせ本文をDBへ保存する
- 推測困難な受付URLを発行する
- 管理者の返信を受付URLへ表示する
- メールアドレスが登録されていれば返信通知を送る
- 通知失敗をOutboxで再試行する
受付URLを回答の正本とし、メールはあくまで通知手段として扱います。
「届いた」を一つの状態にしない
システム上の「届いた」には複数の意味があります。
| 状態 | 意味 | 判定方法 |
|---|---|---|
accepted |
問い合わせを永続化した | DBトランザクション成功 |
notified |
メール事業者が送信要求を受理した | APIまたはSMTPの成功応答 |
viewed |
利用者が受付ページを開いた | 閲覧日時の更新 |
resolved |
対応が完了した | 管理者または利用者の操作 |
メールAPIが成功しても、迷惑メールへの振り分けやアドレス間違いまでは検出できません。そのため、完了画面では「必ずメールが届きます」ではなく、次のように案内します。
問い合わせを受け付けました。返信はこの受付URLで確認できます。メールアドレスを入力した場合は、返信時にも通知します。
使用する構成
- Next.js App Router
- PostgreSQL
-
pg:DB接続 -
zod:入力検証 - 定期実行できるWorkerまたはCron
npm install pg zod
npm install -D @types/pg
環境変数を用意します。
DATABASE_URL=postgresql://app:password@localhost:5432/app
APP_URL=http://localhost:3000
# openssl rand -base64 32 で生成する32バイト鍵
CONTACT_TOKEN_KEY=BASE64_ENCODED_32_BYTE_KEY
データモデル
問い合わせスレッド、メッセージ、通知Outboxを分離します。
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TYPE contact_status AS ENUM (
'new',
'in_progress',
'waiting_user',
'resolved'
);
CREATE TYPE message_author AS ENUM ('visitor', 'operator');
CREATE TABLE contact_threads (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
public_token_hash BYTEA NOT NULL UNIQUE,
public_token_ciphertext TEXT NOT NULL,
email TEXT,
status contact_status NOT NULL DEFAULT 'new',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_viewed_at TIMESTAMPTZ
);
CREATE TABLE contact_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
thread_id UUID NOT NULL REFERENCES contact_threads(id) ON DELETE CASCADE,
author message_author NOT NULL,
body TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE notification_outbox (
id BIGSERIAL PRIMARY KEY,
thread_id UUID NOT NULL REFERENCES contact_threads(id) ON DELETE CASCADE,
message_id UUID NOT NULL REFERENCES contact_messages(id) ON DELETE CASCADE,
channel TEXT NOT NULL CHECK (channel IN ('email')),
state TEXT NOT NULL DEFAULT 'pending'
CHECK (state IN ('pending', 'processing', 'sent', 'failed')),
attempts INTEGER NOT NULL DEFAULT 0,
available_at TIMESTAMPTZ NOT NULL DEFAULT now(),
locked_at TIMESTAMPTZ,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (message_id, channel)
);
CREATE INDEX notification_outbox_poll_idx
ON notification_outbox (state, available_at);
公開トークンはハッシュだけでなく、暗号化した値も保存しています。ハッシュは受付URLの照合に使い、暗号文は返信通知に受付URLを載せるために使います。
DBだけが漏えいしてもトークンをそのまま読めないよう、暗号鍵はDBとは別のSecret Managerや環境変数で管理します。
DB接続とトークン処理
lib/db.tsを作成します。
import { Pool } from "pg";
export const db = new Pool({
connectionString: process.env.DATABASE_URL,
max: 5,
});
続いてlib/contact-token.tsです。
import {
createCipheriv,
createDecipheriv,
createHash,
randomBytes,
} from "node:crypto";
function encryptionKey() {
const key = Buffer.from(process.env.CONTACT_TOKEN_KEY ?? "", "base64");
if (key.length !== 32) {
throw new Error("CONTACT_TOKEN_KEY must be 32 bytes");
}
return key;
}
export function issueContactToken() {
const raw = randomBytes(32).toString("base64url");
return {
raw,
hash: hashContactToken(raw),
ciphertext: encryptContactToken(raw),
};
}
export function hashContactToken(raw: string) {
return createHash("sha256").update(raw).digest();
}
export function encryptContactToken(raw: string) {
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", encryptionKey(), iv);
const encrypted = Buffer.concat([cipher.update(raw, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
return Buffer.concat([iv, tag, encrypted]).toString("base64url");
}
export function decryptContactToken(value: string) {
const payload = Buffer.from(value, "base64url");
const iv = payload.subarray(0, 12);
const tag = payload.subarray(12, 28);
const encrypted = payload.subarray(28);
const decipher = createDecipheriv("aes-256-gcm", encryptionKey(), iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString("utf8");
}
受付URLはパスを知っている人が閲覧できるBearer URLです。十分な長さの乱数を使い、連番IDや短いUUIDの一部を公開キーにしないことが重要です。
問い合わせ受付API
app/api/contact/route.tsを作成します。
import { z } from "zod";
import { db } from "@/lib/db";
import { issueContactToken } from "@/lib/contact-token";
const inputSchema = z.object({
body: z.string().trim().min(10).max(5000),
email: z.union([z.string().trim().email(), z.literal("")]).optional(),
website: z.string().max(0).optional(), // bot向けhoneypot
});
export async function POST(request: Request) {
const parsed = inputSchema.safeParse(await request.json());
if (!parsed.success) {
return Response.json({ error: "invalid_input" }, { status: 400 });
}
const token = issueContactToken();
const client = await db.connect();
try {
await client.query("BEGIN");
const threadResult = await client.query<{ id: string }>(
`INSERT INTO contact_threads
(public_token_hash, public_token_ciphertext, email)
VALUES ($1, $2, $3)
RETURNING id`,
[token.hash, token.ciphertext, parsed.data.email || null],
);
await client.query(
`INSERT INTO contact_messages (thread_id, author, body)
VALUES ($1, 'visitor', $2)`,
[threadResult.rows[0].id, parsed.data.body],
);
await client.query("COMMIT");
return Response.json(
{ receiptUrl: `/contact/t/${token.raw}` },
{ status: 201 },
);
} catch (error) {
await client.query("ROLLBACK");
console.error("contact creation failed", error);
return Response.json({ error: "internal_error" }, { status: 500 });
} finally {
client.release();
}
}
本番ではhoneypotだけに頼らず、IPやセッション単位のレート制限、本文の重複検出、必要に応じたCAPTCHAを追加します。IPアドレスを保存する場合は、保存目的と保持期間も決めてください。
フロントエンドは成功時にreceiptUrlへ遷移させます。
const response = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body, email, website: "" }),
});
if (!response.ok) throw new Error("送信できませんでした");
const { receiptUrl } = await response.json();
location.assign(receiptUrl);
受付ページで返信履歴を表示する
app/contact/t/[token]/page.tsxでは、URLのトークンをハッシュ化して照合します。
import { notFound } from "next/navigation";
import { unstable_noStore as noStore } from "next/cache";
import { db } from "@/lib/db";
import { hashContactToken } from "@/lib/contact-token";
export default async function ContactThreadPage({
params,
}: {
params: Promise<{ token: string }>;
}) {
noStore();
const { token } = await params;
if (!/^[A-Za-z0-9_-]{43}$/.test(token)) notFound();
const result = await db.query(
`SELECT
t.id,
t.status,
m.id AS message_id,
m.author,
m.body,
m.created_at
FROM contact_threads t
JOIN contact_messages m ON m.thread_id = t.id
WHERE t.public_token_hash = $1
ORDER BY m.created_at ASC`,
[hashContactToken(token)],
);
if (result.rowCount === 0) notFound();
await db.query(
`UPDATE contact_threads
SET last_viewed_at = now()
WHERE id = $1`,
[result.rows[0].id],
);
return (
<main>
<h1>問い合わせ履歴</h1>
<p>状態: {result.rows[0].status}</p>
<ol>
{result.rows.map((message) => (
<li key={message.message_id}>
<strong>
{message.author === "operator" ? "運営からの返信" : "あなた"}
</strong>
<p style={{ whiteSpace: "pre-wrap" }}>{message.body}</p>
<time>{new Date(message.created_at).toLocaleString("ja-JP")}</time>
</li>
))}
</ol>
</main>
);
}
本文をReactの通常のテキストノードとして表示すればエスケープされます。問い合わせ本文をdangerouslySetInnerHTMLへ渡してはいけません。
また、受付URLが外部サイトへRefererとして送られないよう、レスポンスヘッダーを設定します。
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
async headers() {
return [
{
source: "/contact/t/:path*",
headers: [
{ key: "Referrer-Policy", value: "no-referrer" },
{ key: "Cache-Control", value: "private, no-store" },
{ key: "X-Robots-Tag", value: "noindex, nofollow" },
],
},
];
},
};
export default nextConfig;
CDNやアクセスログにもURLパスが残り得ます。/contact/t/*をログから除外またはマスクできるか、利用中の基盤を確認してください。
管理者の返信とOutboxを同じトランザクションに入れる
返信保存後にそのままメールAPIを呼ぶと、DB保存には成功したのにプロセスが停止して通知されない可能性があります。逆にメール送信後にDB保存が失敗すると、存在しない返信の通知が届きます。
そこで、返信と通知予定を同じトランザクションで保存します。
const client = await db.connect();
try {
await client.query("BEGIN");
const messageResult = await client.query<{ id: string }>(
`INSERT INTO contact_messages (thread_id, author, body)
VALUES ($1, 'operator', $2)
RETURNING id`,
[threadId, replyBody],
);
await client.query(
`UPDATE contact_threads
SET status = 'waiting_user', updated_at = now()
WHERE id = $1`,
[threadId],
);
await client.query(
`INSERT INTO notification_outbox (thread_id, message_id, channel)
SELECT id, $2, 'email'
FROM contact_threads
WHERE id = $1 AND email IS NOT NULL
ON CONFLICT (message_id, channel) DO NOTHING`,
[threadId, messageResult.rows[0].id],
);
await client.query("COMMIT");
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
この処理を管理画面用APIに置く場合は、コード例の前段で必ず管理者セッションとCSRF対策を検証してください。threadIdを知っているだけで返信できる状態にしてはいけません。
Workerで通知を再試行する
複数Workerが同じ通知を処理しないよう、まず対象行をprocessingへ変更して取得します。
WITH target AS (
SELECT id
FROM notification_outbox
WHERE (
state IN ('pending', 'failed')
AND available_at <= now()
) OR (
state = 'processing'
AND locked_at < now() - interval '10 minutes'
)
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 20
)
UPDATE notification_outbox AS o
SET state = 'processing',
locked_at = now(),
attempts = attempts + 1
FROM target
WHERE o.id = target.id
RETURNING o.*;
取得後はDBトランザクションを閉じてからメールAPIを呼びます。成功時はsent、失敗時は指数バックオフ付きでfailedへ戻します。
const delayMinutes = Math.min(2 ** attempts, 60);
await db.query(
`UPDATE notification_outbox
SET state = 'failed',
available_at = now() + ($2 * interval '1 minute'),
last_error = $3,
locked_at = NULL
WHERE id = $1`,
[outboxId, delayMinutes, safeErrorMessage],
);
メール本文に載せる受付URLは、public_token_ciphertextを復号して組み立てます。
const token = decryptContactToken(thread.public_token_ciphertext);
const receiptUrl = new URL(
`/contact/t/${token}`,
process.env.APP_URL,
).toString();
メール事業者側で処理された直後にWorkerが停止すると、同じ通知が再送される可能性があります。事業者が冪等キーを受け付ける場合は、notification_outbox.idなどをキーとして渡してください。それでも完全なexactly-once配送ではないため、メール本文は重複しても問題が起きない内容にします。
メールアドレスを任意にする理由
メールを必須にすると通知しやすい一方、次の負担が増えます。
- 入力途中の離脱
- 個人情報の管理責任
- アドレス訂正や削除依頼への対応
- メール送信基盤の運用
受付URLを必ず発行すれば、メールなしでも返信を確認できます。ただし、URLを紛失すると復旧できません。本人確認なしでメールアドレスから受付URLを再発行すると、第三者への情報開示につながるためです。
用途に応じて、以下から選びます。
| 方針 | 向いているケース | 主な欠点 |
|---|---|---|
| メール任意+受付URL | 匿名性を残したい | URL紛失時に復旧しにくい |
| メール必須+受付URL | 継続対応が重要 | 個人情報管理が増える |
| ログイン必須 | 契約・課金情報を扱う | 問い合わせ開始の負担が大きい |
問い合わせ本文にも機密情報を書かせない注意書きを置き、保存期間を決めて古いスレッドを削除できるようにします。
自前実装しない場合のウィジェット統合例
問い合わせ基盤そのものを運用したくない場合は、外部のコンタクトレイヤーを利用する選択肢もあります。その実装例の一つがKnocketです。
Knocketは共有用コンタクトページ、Webサイトへ埋め込むライブチャットウィジェット、モバイルWebView SDK、統合Inboxを提供しており、Web版は独自バックエンドを用意せずscriptタグで導入できます。
管理画面で発行されたscriptタグを、URLや属性を書き換えずにルートレイアウトへ配置します。正確なタグは管理画面で発行された内容を使用してください。
// app/layout.tsx
import Script from "next/script";
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="ja">
<body>
{children}
<Script
src="管理画面で発行されたSCRIPT_URL"
strategy="afterInteractive"
/>
</body>
</html>
);
}
Knocketでは訪問者がアカウントを作らずにチャットを開始でき、メッセージをTelegramへ転送し、Telegram上の引用返信をWebサイトの訪問者へ返す構成も取れます。
外部ウィジェットを採用する場合も、次の点は自分のサイト側で確認します。
- プライバシーポリシーへの記載
- Content Security Policyで許可すべき接続先
- 同意取得前に読み込んでよいスクリプトか
- ウィジェットが読み込めない場合の代替連絡先
- サービスを将来切り替える場合のデータ移行方法
厳格なCSPを使っている場合は、実際に発行されたscriptのホストと通信先を確認し、必要なオリジンだけをscript-srcやconnect-srcへ追加してください。*を許可するのは避けます。
実装チェックリスト
- 問い合わせ保存と返信保存がトランザクション化されている
- 受付URLに十分な長さの乱数を使っている
- 公開トークンを平文だけで保存していない
-
受付ページを
no-store、noindexにしている - URLをRefererやログへ漏らさない設計になっている
- メール送信とDB更新をOutboxで分離している
- 通知の重複送信を許容できるメール内容にしている
- 管理者APIに認証とCSRF対策がある
- レート制限とスパム対策がある
- 個人情報の保存目的・保持期間・削除方法を決めている
- メールが届かなくても受付URLから回答を確認できる
まとめ
問い合わせ導線では、フォームの見た目よりも「保存された」「通知された」「閲覧された」を分けて設計することが重要です。
最小構成でも、推測困難な受付URL、メッセージ履歴、任意のメール通知、Outboxによる再試行があれば、送信後の不安を減らしつつ運用上の障害にも対応できます。自前実装と外部サービスのどちらを選ぶ場合でも、返信の正本をどこに置くか、通知失敗時に利用者がどう回答へ到達できるかを先に決めておくと設計がぶれません。
開示:筆者は Knocket の開発・運営に関わっています。本記事では中立的なランキングではなく、実装例の一つとして紹介します。