🎃

Cloudflare Turnstile & Email Routingで実現するコンタクトフォーム

2023/10/30に公開

Cloudflare には Cloudflare TurnstileCloudflare Email Routing というサービスがあるので、それらを使ってコンタクトフォームを作ってみました。

メールが送られてきている様子

https://github.com/naporin0624/cloudflare-turnstile-email-routing

Cloudflare Turnstile

https://www.cloudflare.com/ja-jp/products/turnstile/

CAPTCHA の cloudfalre 版です。

Turnstileは、CAPTCHAのないフラストレーションフリーなWeb体験をサイト訪問者に提供します。必要なのは簡単な無料コードスニペットだけです。さらに、Turnstileは、データプライバシーの心配もCAPTCHAの煩わしさもなく、不正使用を阻止し、Web訪問者が実在の人物であることを確認します。

という記述があり、web ページを訪れるユーザーにも優しいみたいです。

Turnstile には 3 つの Widget Mode があります。それぞれ Light|Dark モードもあるのでサイトに合わせた Widget を選ぶことができます。

https://developers.cloudflare.com/turnstile/reference/widget-types/

Turnstile は web 訪問者の確認部分であるフロントエンドの実装と、送られてきた内容が Turnstile によって検証されたことを確認するバックエンドの実装が必要です。

Turnstile をサイトに導入する

https://developers.cloudflare.com/turnstile/get-started/

を読み進めながら設定していきます。

client 側

react の場合は react-turnstile を利用するとすぐ使い始めることができます。

https://github.com/Le0developer/react-turnstile

import { useCallback, useId, useState } from "react";
import Turnstile, { useTurnstile } from "react-turnstile";

const App = () => {
  const id = useId();
  const [loading, setLoading] = useState(true);
  const handleVerify = useCallback(() => {
    setLoading(false);
  }, []);

  const turnstile = useTurnstile();
  const handleSubmit = useCallback(
    async (e: React.FormEvent<HTMLFormElement>) => {
      e.preventDefault();
      const res = await fetch("/api/turnstile", {
        method: "POST",
        body: new FormData(e.currentTarget),
      });

      const { ok } = await res.json<{ ok: boolean }>();
      if (ok) {
        alert("ok");
      } else {
        turnstile.reset();
        alert("ng");
      }
    },
    [turnstile],
  );

  return (
    <main>
      <section>
        <h1>Cloudflare turnstile + Email Routing</h1>
        <form onSubmit={handleSubmit}>
          <div>
            <label htmlFor={`${id}-description`}>description</label>
            <textarea name="description" id={`${id}-description`} required />
          </div>

          <Turnstile sitekey="YOUR_SITE_KEY" onVerify={handleVerify} />

          <button type="submit" disabled={loading}>
            送信
          </button>
        </form>
      </section>
    </main>
  );
};

server 側

今回は hono を利用しました。
TURNSTILE_SECRET_KEY は環境変数に設定しておきます。

import { Hono } from "hono";

type ErrorCode =
  | "missing-input-secret"
  | "invalid-input-secret"
  | "missing-input-response"
  | "invalid-input-response"
  | "invalid-widget-id"
  | "invalid-parsed-secret"
  | "bad-request"
  | "timeout-or-duplicate"
  | "internal-error";

type SiteVerify =
  | {
      success: true;
      challenge_ts: string;
      hostname: string;
      "error-codes": [];
      action: string;
      cdata: string;
    }
  | {
      success: false;
      "error-codes": ErrorCode[];
    };

type Bindings = {
  TURNSTILE_SECRET_KEY: string;
};
type HonoEnv = {
  Bindings: Bindings;
};

const app = new Hono<HonoEnv>();

app.post("/api/turnstile", async (c) => {
  const form = await c.req.formData();
  const textarea = form.get("description")?.toString();
  const token = form.get("cf-turnstile-response")?.toString();
  const ip = c.req.headers.get("CF-Connecting-IP");
  if (token === undefined) return c.body("token is undefined", 400);

  const formData = new FormData();
  formData.append("secret", c.env.TURNSTILE_SECRET_KEY);
  formData.append("response", token);
  if (ip !== null) {
    formData.append("remoteip", ip);
  }

  const url = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
  const result = await fetch(url, {
    body: formData,
    method: "POST",
  });

  const outcome = await result.json<SiteVerify>();
  if (!outcome.success) {
    return c.json({ ok: false, "error-codes": outcome["error-codes"] }, { status: 500 });
  }

  return c.json({ ok: true });
});

export default app;

Cloudflare Email Routing

https://www.cloudflare.com/ja-jp/developer-platform/email-routing/

cloudflare worker で email を送信、転送することができるサービスです。

このサービスを使用して cloudfalre worker を用いて特定の email に form の内容を送信します。

メールを送信する

https://developers.cloudflare.com/email-routing/email-workers/send-email-workers/#types-of-bindings

を参考にメールを送信します。cloudflare worker からメールを送信するためには、以下の作業をする必要があります。

you need to enable Email Routing and have at least one verified email address.

これができたら、wrangler.toml に binding を追加します。

send_email = [
  {type = "send_email", name = "EMAIL" }
]

cloudflare:emailmimetext を import してメールを送信します。

import { EmailMessage } from "cloudflare:email";
import { Hono } from "hono";
import { serveStatic } from "hono/cloudflare-workers";
import { createMimeMessage } from "mimetext";

type ErrorCode =
  | "missing-input-secret"
  | "invalid-input-secret"
  | "missing-input-response"
  | "invalid-input-response"
  | "invalid-widget-id"
  | "invalid-parsed-secret"
  | "bad-request"
  | "timeout-or-duplicate"
  | "internal-error";

type SiteVerify =
  | {
      success: true;
      challenge_ts: string;
      hostname: string;
      "error-codes": [];
      action: string;
      cdata: string;
    }
  | {
      success: false;
      "error-codes": ErrorCode[];
    };

type Bindings = {
  TURNSTILE_SECRET_KEY: string;
  EMAIL: SendEmail;
};
type HonoEnv = {
  Bindings: Bindings;
};

const app = new Hono<HonoEnv>();
app.get("*", serveStatic({ root: "./" }));

app.post("/api/turnstile", async (c) => {
  const form = await c.req.formData();
  const textarea = form.get("description")?.toString();
  const token = form.get("cf-turnstile-response")?.toString();
  const ip = c.req.header("CF-Connecting-IP");
  if (token === undefined) return c.body("token is undefined", 400);

  const formData = new FormData();
  formData.append("secret", c.env.TURNSTILE_SECRET_KEY);
  formData.append("response", token);
  if (ip !== null && ip !== undefined) {
    formData.append("remoteip", ip);
  }

  const url = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
  const result = await fetch(url, {
    body: formData,
    method: "POST",
  });

  const outcome = (await result.json()) as SiteVerify;
  if (!outcome.success) {
    return c.json({ ok: false, "error-codes": outcome["error-codes"] }, { status: 500 });
  }

  try {
    const msg = createMimeMessage();
    const from = "";
    const to = "";
    msg.setSender({ name: "テストユーザー", addr: from });
    msg.setRecipient(to);

    msg.setSubject("テストユーザー");
    msg.addMessage({
      contentType: "text/html",
      data: `
        <h1>テストメール</h1>
        <p style="white-space: pre-wrap;">${textarea}</p>
      `,
    });

    msg.addMessage({
      contentType: "text/plain",
      data: `hello world\n${textarea}`,
    });

    const message = new EmailMessage(from, to, msg.asRaw());
    await c.env.EMAIL.send(message);

    return c.json({ ok: true });
  } catch (e) {
    if (e instanceof Error) {
      return c.json({ ok: false, message: e.message }, { status: 500 });
    }

    return c.json({ ok: false }, { status: 500 });
  }
});

export default app;

まとめ

コンタクトフォームの内容をメールに送信するという要件が来たときに、bot を弾く実装する手段として turnstile はかなり手軽だと感じました。

cloudfalre email sending まわりの開発体験があまりよくないですが、おいおい改善されることを期待してゆるく使っていきたいなと思っています。

ありがとうございました。

Discussion