0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

個人開発の問い合わせフォームを「返事が届く導線」まで実装する

0
Last updated at Posted at 2026-07-23

はじめに

問い合わせフォームは、POSTに成功しただけでは完成ではありません。利用者が本当に知りたいのは、次の3点です。

  • 送信内容が受け付けられたか
  • いつ、どこに返事が届くか
  • メールを入力しなかった場合でも回答を確認できるか

現在のプロダクト開発では、単発のフォームだけでなく、問い合わせ後も会話や状態を追跡できる導線が選ばれやすくなっています。ただし、個人開発では大規模なサポートシステムを最初から構築する必要はありません。

この記事では、次の最小構成をNext.js App RouterとPostgreSQLで実装します。

  1. 問い合わせを保存する
  2. 推測できない受付URLを発行する
  3. 任意のメールアドレスへ受付通知を送る
  4. 管理者の返信を同じ受付URLに表示する
  5. メール送信失敗を再試行できるようにする

「届いた」を3段階に分ける

システム上の「届いた」は曖昧です。実装前に、次の状態を分離します。

状態 意味 判定方法
accepted サーバーが問い合わせを保存した DBトランザクション成功
notified 通知チャネルへ送信した SMTP/APIの成功応答
viewed 利用者が回答画面を開いた 閲覧日時の記録

メール送信に成功しても、迷惑メールへの振り分けやアドレス間違いは起こり得ます。そのためUIでは「必ずメールが届きます」ではなく、次のように案内します。

問い合わせを受け付けました。この受付URLを保存してください。メールアドレスを入力した場合は、返信時にも通知します。

受付URLを正本、メールを通知手段として扱うのがポイントです。

導線を設計する

問い合わせ画面には、少なくとも以下を表示します。

  • 問い合わせ本文
  • 返信通知用メールアドレス(任意)
  • 返信目安または対応方針
  • 個人情報を本文へ書かないようにする注意
  • 送信後に発行される受付URLの用途

メールアドレスを必須にすると返信は通知しやすくなりますが、入力の負担と個人情報の管理責任が増えます。個人開発では「メールは任意、受付URLは必ず発行」という構成が扱いやすいです。

データモデル

問い合わせスレッド、メッセージ、通知Outboxを分けます。

CREATE EXTENSION IF NOT EXISTS pgcrypto;

CREATE TYPE thread_status AS ENUM (
  'new',
  'triaged',
  'waiting_user',
  'resolved'
);

CREATE TABLE contact_threads (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  public_token_hash TEXT NOT NULL UNIQUE,
  email TEXT,
  status thread_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,
  sender TEXT NOT NULL CHECK (sender IN ('visitor', 'operator')),
  body TEXT NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE notification_outbox (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  thread_id UUID NOT NULL REFERENCES contact_threads(id) ON DELETE CASCADE,
  kind TEXT NOT NULL CHECK (kind IN ('accepted', 'replied')),
  recipient TEXT NOT NULL,
  payload JSONB NOT NULL,
  attempts INTEGER NOT NULL DEFAULT 0,
  available_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  sent_at TIMESTAMPTZ,
  last_error TEXT,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX notification_outbox_pending_idx
  ON notification_outbox (available_at)
  WHERE sent_at IS NULL;

受付URL用のトークンは平文で保存しません。データベースが閲覧されたときに受付URLまで復元されないよう、ランダムトークンのハッシュだけを保存します。

必要なパッケージ

npm install pg zod nodemailer
npm install --save-dev @types/pg @types/nodemailer

環境変数は次のように用意します。

DATABASE_URL=postgresql://app:password@localhost:5432/app
APP_ORIGIN=https://example.com
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=your-user
SMTP_PASSWORD=your-password
MAIL_FROM=support@example.com

APP_ORIGINはリクエストのHostヘッダーから組み立てず、信頼できる固定値を使います。

DB接続を作る

// src/lib/db.ts
import { Pool } from "pg";

export const db = new Pool({
  connectionString: process.env.DATABASE_URL,
});

問い合わせ受付API

256bitのランダムトークンを作り、利用者には平文を返し、DBにはSHA-256ハッシュだけを保存します。

// src/app/api/contact/route.ts
import crypto from "node:crypto";
import { z } from "zod";
import { db } from "@/lib/db";

const schema = z.object({
  body: z.string().trim().min(1).max(5000),
  email: z.union([z.string().trim().email(), z.literal("")]).optional(),
  website: z.string().max(0).optional(), // honeypot
});

const hashToken = (token: string) =>
  crypto.createHash("sha256").update(token).digest("hex");

export async function POST(request: Request) {
  const parsed = schema.safeParse(await request.json());

  if (!parsed.success) {
    return Response.json(
      { error: "入力内容を確認してください" },
      { status: 400 },
    );
  }

  // ボットが埋めやすい隠しフィールド。正常終了に見せて破棄する
  if (parsed.data.website) {
    return Response.json({ accepted: true }, { status: 202 });
  }

  const token = crypto.randomBytes(32).toString("base64url");
  const tokenHash = hashToken(token);
  const email = parsed.data.email || null;
  const client = await db.connect();

  try {
    await client.query("BEGIN");

    const threadResult = await client.query<{ id: string }>(
      `INSERT INTO contact_threads (public_token_hash, email)
       VALUES ($1, $2)
       RETURNING id`,
      [tokenHash, email],
    );

    const threadId = threadResult.rows[0].id;

    await client.query(
      `INSERT INTO contact_messages (thread_id, sender, body)
       VALUES ($1, 'visitor', $2)`,
      [threadId, parsed.data.body],
    );

    if (email) {
      await client.query(
        `INSERT INTO notification_outbox
           (thread_id, kind, recipient, payload)
         VALUES ($1, 'accepted', $2, $3::jsonb)`,
        [
          threadId,
          email,
          JSON.stringify({
            statusUrl: `${process.env.APP_ORIGIN}/contact/status/${token}`,
          }),
        ],
      );
    }

    await client.query("COMMIT");

    return Response.json(
      {
        accepted: true,
        statusUrl: `/contact/status/${token}`,
      },
      { status: 201 },
    );
  } catch (error) {
    await client.query("ROLLBACK");
    console.error("contact submission failed", error);
    return Response.json(
      { error: "受付に失敗しました。時間をおいて再度お試しください" },
      { status: 500 },
    );
  } finally {
    client.release();
  }
}

DB保存とOutbox作成を同じトランザクションに含めています。API内で直接メールを送ると、次の不整合が発生します。

  • DB保存後、メール送信前にプロセスが終了する
  • メール送信後、DBトランザクションが失敗する
  • SMTPの遅延でAPIがタイムアウトする

Outbox方式なら、問い合わせの保存と「通知すべき事実」を同時に確定できます。

送信フォーム

"use client";

import { FormEvent, useState } from "react";

export default function ContactForm() {
  const [statusUrl, setStatusUrl] = useState<string>();
  const [submitting, setSubmitting] = useState(false);
  const [error, setError] = useState<string>();

  async function submit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setSubmitting(true);
    setError(undefined);

    const form = new FormData(event.currentTarget);
    const response = await fetch("/api/contact", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        body: form.get("body"),
        email: form.get("email"),
        website: form.get("website"),
      }),
    });

    const result = await response.json();
    setSubmitting(false);

    if (!response.ok) {
      setError(result.error ?? "送信に失敗しました");
      return;
    }

    setStatusUrl(result.statusUrl);
  }

  if (statusUrl) {
    return (
      <section aria-live="polite">
        <h2>受け付けました</h2>
        <p>返信を確認できるよう、次のURLを保存してください。</p>
        <a href={statusUrl}>{location.origin + statusUrl}</a>
      </section>
    );
  }

  return (
    <form onSubmit={submit}>
      <label>
        問い合わせ内容
        <textarea name="body" required maxLength={5000} />
      </label>

      <label>
        返信通知用メールアドレス(任意)
        <input name="email" type="email" autoComplete="email" />
      </label>

      <input
        name="website"
        tabIndex={-1}
        autoComplete="off"
        aria-hidden="true"
        className="hidden"
      />

      <button disabled={submitting}>
        {submitting ? "送信中…" : "送信する"}
      </button>

      {error && <p role="alert">{error}</p>}
    </form>
  );
}

送信中はボタンを無効化します。ただし、通信タイムアウト後の再送などは防ぎきれません。本番ではクライアントが生成したIdempotency Keyを短期間保存し、同じキーによる二重登録を防ぐ設計も検討します。

受付URLから会話を取得する

URLのトークンをハッシュ化してスレッドを検索します。メールアドレスや管理者用メモはレスポンスに含めません。

// src/app/api/contact/status/[token]/route.ts
import crypto from "node:crypto";
import { db } from "@/lib/db";

export async function GET(
  _request: Request,
  context: { params: Promise<{ token: string }> },
) {
  const { token } = await context.params;
  const tokenHash = crypto
    .createHash("sha256")
    .update(token)
    .digest("hex");

  const result = await db.query(
    `SELECT
       t.id,
       t.status,
       t.created_at,
       COALESCE(
         json_agg(
           json_build_object(
             'sender', m.sender,
             'body', m.body,
             'createdAt', m.created_at
           ) ORDER BY m.created_at
         ) FILTER (WHERE m.id IS NOT NULL),
         '[]'::json
       ) AS messages
     FROM contact_threads t
     LEFT JOIN contact_messages m ON m.thread_id = t.id
     WHERE t.public_token_hash = $1
     GROUP BY t.id`,
    [tokenHash],
  );

  if (result.rowCount === 0) {
    return Response.json({ error: "見つかりません" }, { status: 404 });
  }

  await db.query(
    `UPDATE contact_threads
     SET last_viewed_at = now()
     WHERE id = $1`,
    [result.rows[0].id],
  );

  return Response.json({
    status: result.rows[0].status,
    createdAt: result.rows[0].created_at,
    messages: result.rows[0].messages,
  });
}

本文はプレーンテキストとして描画します。MarkdownやHTMLを許可する場合は、保存時または表示時に許可リスト方式でサニタイズしてください。

管理者が返信するときの処理

管理画面の返信APIでは、認証とCSRF対策を前提に、以下を1トランザクションで実行します。

BEGIN;

INSERT INTO contact_messages (thread_id, sender, body)
VALUES ($1, 'operator', $2);

UPDATE contact_threads
SET status = 'waiting_user', updated_at = now()
WHERE id = $1;

INSERT INTO notification_outbox (thread_id, kind, recipient, payload)
SELECT id, 'replied', email, jsonb_build_object('statusUrl', $3)
FROM contact_threads
WHERE id = $1 AND email IS NOT NULL;

COMMIT;

メールアドレスがない場合でも、返信内容は受付URLから確認できます。利用者から追記できるようにする場合は、同じトークンでメッセージを追加するPOST APIを用意し、ステータスをtriagedへ戻します。

Outboxワーカーで通知する

ワーカーや定期実行ジョブは、未送信レコードをロックして取得します。

SELECT id, recipient, kind, payload
FROM notification_outbox
WHERE sent_at IS NULL
  AND available_at <= now()
ORDER BY created_at
LIMIT 20
FOR UPDATE SKIP LOCKED;

送信成功時はsent_atを更新します。失敗時は試行回数を増やし、指数バックオフで次回時刻を設定します。

UPDATE notification_outbox
SET attempts = attempts + 1,
    available_at = now() + make_interval(
      secs => LEAST(3600, power(2, attempts + 1)::integer * 30)
    ),
    last_error = $2
WHERE id = $1;

無限再試行は避け、一定回数を超えたレコードは管理者が確認できるようにします。エラーログへメール本文や問い合わせ本文をそのまま出力しないことも重要です。

非公開問い合わせと公開フィードバックを分ける

すべてを同じ受信箱へ入れると、請求やアカウントの相談と、一般的な機能要望が混ざります。入口で分類すると運用しやすくなります。

種類 適した導線
アカウント、請求、個別障害 非公開の問い合わせ
一般的な機能要望 公開フィードバックまたは投票
再現可能な不具合 Issue trackerまたは非公開フォーム
セキュリティ報告 専用の非公開窓口

公開フィードバックは重複要望をまとめやすい一方、個人情報や脆弱性報告には向きません。「何を公開してよいか」を利用者に判断させるだけでなく、用途ごとに入口を明示します。

スパム対策と安全性

最低限、次の対策を追加します。

  • IPアドレスまたは匿名化キーによるレート制限
  • honeypotと本文長の制限
  • URLトークンをアクセスログや分析ツールへ送らない設定
  • 受付ページへReferrer-Policy: no-referrerを設定
  • 管理画面の認証、CSRF対策、操作ログ
  • 保持期間を決め、不要なメールアドレスと本文を削除
  • 受付URLを問い合わせ本人だけが知る秘密情報として扱う

URLトークン方式は手軽ですが、URLが共有されると第三者も閲覧できます。機密性の高い問い合わせを扱う場合は、メールによるワンタイム認証やログイン済みアカウントとの紐付けを選びます。

運用で確認する指標

問い合わせ数だけでは、導線が機能しているか判断できません。個人を過剰に追跡せず、次のイベントを確認します。

  • contact_submitted: DBへ保存された
  • contact_notification_sent: 通知送信に成功した
  • contact_reply_created: 管理者が返信した
  • contact_status_viewed: 返信後に受付ページが閲覧された
  • contact_resolved: 対応を完了した

特に、返信済みなのに受付ページが閲覧されないケースは、通知失敗や案内不足を見直す材料になります。

自前実装を減らす選択肢

小さなWebウィジェットを使う実装例として、Knocketは共有用コンタクトページ、scriptタグで導入できるWebライブチャットウィジェット、モバイルWebView SDK、統合受信箱を提供し、独自バックエンドなしで設置でき、アカウントを持たない訪問者も会話を開始できるほか、Telegramへメッセージを転送して引用返信をWebサイト側へ返す構成に対応しており、現在のコアプロダクトはカード登録不要の無料として案内されています。

発行されたウィジェット用スニペットを、通常は</body>の直前へ配置します。実際のURLや属性は管理画面で発行された値をそのまま使用します。

<!-- 概念例。実際には発行されたscriptタグへ置き換える -->
<script src="GENERATED_WIDGET_SCRIPT_URL"></script>

既製サービスを使う場合も、次の点は事前に確認します。

  • 訪問者を再識別する方法
  • 返信が届く条件と通知チャネル
  • データ保持、削除、エクスポート方法
  • 障害時の代替連絡先
  • 機密情報を入力させない案内

まとめ

「問い合わせを送れる」と「返事が届く」は別の要件です。最小構成でも、次の設計を入れると会話が途切れにくくなります。

  • DB保存成功を受付完了とする
  • 推測困難な受付URLを必ず発行する
  • メールは任意の通知チャネルとして使う
  • 返信と通知要求を同じトランザクションで保存する
  • 通知はOutboxから再試行する
  • 非公開問い合わせと公開フィードバックを分ける

フォームの見た目より先に、「送信後、利用者がどこへ戻ればよいか」を決めることが重要です。

開示:筆者は Knocket の開発・運営に関わっています。本記事では中立的なランキングではなく、実装例の一つとして紹介します。

0
0
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
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?