HugoサイトにCloudflare WorkersとResendでお問い合わせフォームを実装した話

Hugoで作った静的サイトにお問い合わせフォームを実装しました。静的サイトはサーバーサイドの処理ができないため、フォームの送信処理には別の仕組みが必要です。今回は Cloudflare Workers(サーバーレス関数)と Resend(メール送信API)を組み合わせた構成にしました。

全体の構成

ブラウザ(フォーム送信)
  ↓ fetch (POST /JSON)
Cloudflare Workers(メール送信処理)
  ↓ Resend API
メール受信(Gmail)

Cloudflare Workers の無料枠(1日10万リクエスト)と Resend の無料枠(月3,000通)の範囲で十分運用できますので、小規模サイトであればコストゼロで実現できます。

1. Resend のセットアップ

アカウント作成とドメイン認証

resend.com でアカウントを作成し、送信元ドメインを登録します。

Domains → Add Domain でドメイン(例:jiru-labo.com)を追加すると、DNS に追加すべきレコードが表示されます。Cloudflare の DNS 管理画面でそれらのレコードを追加すると、数分で認証が完了します。

API キーの作成

API Keys → Create API Key で API キーを発行しておきます。後で Worker の環境変数に設定します。

2. Cloudflare Workers の作成

プロジェクト作成

mkdir contact-worker
cd contact-worker

wrangler.toml を作成します:

name = "contact-worker"
main = "src/index.js"
compatibility_date = "2024-01-01"

Worker の実装

src/index.js を作成します:

const ALLOWED_ORIGINS = [
  'https://jiru-labo.com',
  'http://localhost:1313',
];

export default {
  async fetch(request, env) {
    const origin = request.headers.get('Origin') || '';
    const isAllowed = ALLOWED_ORIGINS.includes(origin) || origin.endsWith('.pages.dev');

    const corsHeaders = {
      'Access-Control-Allow-Origin': isAllowed ? origin : ALLOWED_ORIGINS[0],
      'Access-Control-Allow-Methods': 'POST, OPTIONS',
      'Access-Control-Allow-Headers': 'Content-Type',
    };

    if (request.method === 'OPTIONS') {
      return new Response(null, { status: 204, headers: corsHeaders });
    }

    if (request.method !== 'POST') {
      return new Response('Method Not Allowed', { status: 405, headers: corsHeaders });
    }

    const { name, email, subject, message } = await request.json();

    if (!name || !email || !message) {
      return new Response(JSON.stringify({ error: '必須項目が不足しています' }), {
        status: 400,
        headers: { ...corsHeaders, 'Content-Type': 'application/json' },
      });
    }

    const res = await fetch('https://api.resend.com/emails', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${env.RESEND_API_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        from: 'お問い合わせフォーム <contact@jiru-labo.com>',
        to: [env.TO_EMAIL],
        reply_to: email,
        subject: subject ? `[お問い合わせ] ${subject}` : `[お問い合わせ] ${name}様より`,
        html: `<p>お名前:${name}</p><p>メール:${email}</p><p>メッセージ:${message}</p>`,
      }),
    });

    if (!res.ok) {
      return new Response(JSON.stringify({ error: 'メール送信に失敗しました' }), {
        status: 500,
        headers: { ...corsHeaders, 'Content-Type': 'application/json' },
      });
    }

    return new Response(JSON.stringify({ ok: true }), {
      status: 200,
      headers: { ...corsHeaders, 'Content-Type': 'application/json' },
    });
  },
};

CORS 設定がポイントです。ブラウザからの fetch リクエストには Origin ヘッダーが付くため、許可するオリジンを明示しておかないと、本番サイトからのリクエストがブロックされます。ローカル開発用の localhost:1313 も忘れずに追加しておきます。

デプロイ

# Cloudflare にログイン(初回のみ)
npx wrangler login

# シークレットを設定
npx wrangler secret put RESEND_API_KEY
npx wrangler secret put TO_EMAIL

# デプロイ
npx wrangler deploy

API キーなどの機密情報はコードに直接書かず、wrangler secret で環境変数として設定します。デプロイ後に https://contact-worker.<サブドメイン>.workers.dev という URL が発行されます。

なお、npm install -g wrangler でグローバルインストールしようとしたところ、/usr/local/lib/node_modules が root 所有のため権限エラーになりました。npx wrangler を使えばインストール不要で動作しますので、こちらをお勧めします。

3. Hugo 側のフォーム実装

content/pages/contact/index.md に HTML フォームと送信スクリプトを記述します:

<form id="contact-form" novalidate>
  <input type="text" name="name" required>
  <input type="email" name="email" required>
  <input type="text" name="subject">
  <textarea name="message" required></textarea>
  <button type="submit">送信する</button>
  <div id="cf-status"></div>
</form>

<script>
var WORKER_URL = 'https://contact-worker.jiru-labo.workers.dev';

document.getElementById('contact-form').addEventListener('submit', function(e) {
  e.preventDefault();
  fetch(WORKER_URL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      name: this.elements['name'].value,
      email: this.elements['email'].value,
      subject: this.elements['subject'].value,
      message: this.elements['message'].value,
    })
  })
  .then(function(res) { return res.json(); })
  .then(function(data) {
    document.getElementById('cf-status').textContent =
      data.ok ? '送信しました。' : data.error;
  });
});
</script>

ハマったポイント

送信元ドメインの typo:Worker の from アドレスを contact@jiru-lab.com と書いていましたが、Resend に登録したドメインは jiru-labo.com(labo)でした。Resend からは validation_error が返るだけでわかりにくかったため、エラーレスポンスに詳細を含めてデバッグしました。

CORS エラー:ローカルで動作確認したとき、localhost がオリジン許可リストに入っておらずブロックされていました。curl での直接テストは通るのにブラウザからだけ失敗するときは CORS を疑うとよいです。

まとめ

Cloudflare Workers と Resend を組み合わせることで、静的サイトでも無料でお問い合わせフォームを実装できました。設定のポイントは以下の3点です。

ポイント 内容
ドメイン認証 Resend で送信元ドメインを必ず認証する
CORS 設定 本番・ローカル両方のオリジンを許可リストに追加する
シークレット管理 API キーはコードに書かず wrangler secret で設定する
← トップページに戻る