Email Routing を使って Cloudflare フリープランだけでメールフォームを実装する方法

本記事は 2024 年 6 月 8 日現在の情報をもとに作成しています。時間経過に伴って各サービス・ソフトの仕様が変わっている場合があります。

1. Cloudflare ユーザー向けの MailChannels API は EOL になります

久々のブログ投稿です。ここ 1~2 年ほど余裕が無くて更新できてませんでした(やる気が出なかったともいいます)

比較的暇になったこのタイミングで、ブログ執筆活動を再開する為に、(なぜか)会社サイトを Netlify から Cloudflare に移転することにしたのです。 Netlify は新型コロナ禍の発生後あたりから、プライベートリポジトリだと無料枠が使えなくなったので、 そのような制限のない Cloudflare へ移転です。 要するに 19 ドルをケチる感じですが、今は円安のご時世なので、会社サイトの様なアクセス数が多くないサイトではなるべく節約したい感じです。

会社サイトには(基本、営業メールしか来ない)メールフォームがあるので、Netlify Forms で実装されていたフォームを別の何かに移す必要があります。

ネットで「Cloudflare でメール送信する方法」を調べると、たいてい MailChannels という外部サービスを使って実装する方法にたどり着きます。 直接エンドポイントを fetch する方法や、Cloudflare Pages プラグインを使用する方法などです。

実際、筆者もそれを読んで MailChannels を使用する方法で実装したのですが、API のレスポンスに EOL の案内が埋め込まれていました。

https://support.mailchannels.com/hc/en-us/articles/26814255454093-End-of-Life-Notice-Cloudflare-Workers

これによると Cloudflare ユーザー向けの無料 API は 2024 年 6 月 30 日に終了 とあります。なんと今月末です。アウチ!

2. 代替サービス: Email Routing

公式のドキュメント(Email Routing → Email Workers) を見ると、 Email Routing がメールフォーム送信に使用できそうです。

Email Routing は Cloudflare で管理する独自ドメインを使って転送専用のメールアドレスが作れたり、 Email Workers でメール受信イベントをフックして自動返信したり出来るサービスです。フリープランでも使用可能です。

Cloudflare Workers からメール送信するだけなら DNS に MX レコードを登録したり、カスタムアドレスを作成したりも不要で、 単に宛先アドレスの登録と確認だけ行えばよいです。

問題は Email Routing のバインディングが Cloudflare Pages Functions で利用できない点です。 将来的にはできるようになるかもしれませんが、今のところ、wrangler.toml でバインディングを設定しても動作しません。

とはいえ Cloudflare Workers からは利用可能ですので、 メール送信 Worker を作り、サービスバインディングすれば、Pages Functions からメール送信できそうです。

サービスバインディングとは Worker から別の Worker を呼び出す機能で、Workers のサブセットである Pages Functions でも利用できます。

3. 準備

3.1. 前提条件

最初に Cloudflare のアカウント登録が必要です。

次に、本エントリでは扱いませんが、Cloudflare で独自ドメインを管理していることを前提とします。 Pages で独自ドメインのサイトをホスティングする際は、必ず Cloudflare でドメインを管理していなければなりません。

Cloudflare Dashboard で「サイトを追加する」ボタンをクリックして既存のドメインを追加するか、 Cloudflare Registrar で新しいドメインを取得してください。

また、当然ですがドメイン同様にサイトホスティング自体も Cloudflare Pages で行っていることを前提とします。

参考:https://developers.cloudflare.com/pages/get-started/

更に、ローカル環境には Node.js(v18 以降)と npm をインストールしてください。

3.2. 宛先アドレスの追加

ドメインの準備が出来たら、Cloudflare Dashboard から登録したドメインを選択してください。

次の手順で宛先メールアドレスを登録し、確認してください。

  1. 「メール アドレス」→「Email Routing」をクリック
  2. 「始める」をクリック
  3. 「開始をスキップする」をクリック
  4. 「宛先アドレス」をクリック
  5. 「宛先アドレスを追加」をクリック
  6. 「宛先アドレス」にメールを受信したいアドレスを入力してください(例:[email protected]
  7. 「保存」をクリック。
  8. 確認待ち の状態で宛先アドレスが追加されます。アドレス宛に確認メールが送信されているので(受信箱に無ければ 迷惑メール フォルダ も確認)、メールに表示された「Verify email address」をクリックしてブラウザき、ページの指示に従えば完了です。

Dashboard に「Email Routing は現在無効になっており・・・」と警告が表示されますが、無視して構いません。

宛先アドレスの登録は、Cloudflare アカウントに登録されたすべてのドメインで共通です。

4. メール送信 Worker を作成

4.1. プロジェクト作成

参考:https://developers.cloudflare.com/workers/get-started/guide/

Worker を作るには Cloudflare の開発用 CLI である wrangler を使用します。

まず、以下の手順でプロジェクトフォルダを作ります

  1. コマンドラインで npm create cloudflare@latest を実行。
  2. 質問 In which directory do you want to create your application? では Worker の名前を入力。 この名前でプロジェクトフォルダが作成されます。
  3. 質問 What type of application do you want to create? では "Hello World" Worker を選択。
  4. 質問 Do you want to use TypeScript? では TypeScript を使いたい場合 Yes を選択。そうでない場合は No。 以降の説明では、TypeScript を選んだものとします。
  5. 質問 Do you want to use git for version control? では git でコードを管理する場合 Yes を選択。そうでない場合は No
  6. 質問 Do you want to deploy your application? では No を選択。

作成されたプロジェクトフォルダに移動してください。

メール送信に mimetext を使うので追加してください。

$ npm i mimetext

4.2. wrangler ログイン

wrangler で Cloudflare にログインしてください。

$ npx wrangler login

ブラウザが開く(自動で開かなければ表示された URL をブラウザのアドレスバーにコピー&ペーストして開く)ので、 表示された権限を Allow してください。

4.3. Email Routing バインディング追加

参考:https://developers.cloudflare.com/email-routing/email-workers/send-email-workers/

プロジェクトルートにある wrangler.toml を開き、ルートテーブルに以下の行を追加してください。

workers_dev = false

send_email = [
    { name = "SEB" },
]

[vars]
SENDER = { name = "Contact Mail Sender", addr = "[email protected]" }
RECIPIENT = { name = "Contact Mail Recipient", addr = "[email protected]" }
  • workers_dev = false は Worker が URL でトリガーされないことを意味します。
  • send_email は Email Routing のバインディングで、 SEB は Worker コード内でアクセスできるプロパティ名になり(Send Email Binding の略?)、これ以外のお好きな名前に変更できます。
  • vars テーブルで環境変数を定義します。
    • SENDER はメールの送り主情報です。name に名前、addr にメールアドレスを設定してください。存在するドメインでないとエラーになります。
    • RECIPINET はメールの宛先情報です。name に名前、addr にメールアドレスを設定してください。確認済みの宛先アドレスでないとエラーになります。

TypeScript を使用する場合は、コマンド

$ npx wrangler types

を実行して型定義ファイル worker-configuration.d.ts を生成してください。 これは wrangler.toml を編集する度に実行する必要があります。

バインディングでドメインを指定する必要はありません。上記だけで登録済みの宛先アドレスにメール送信できます (Email Routing のルーティング機能は使わず、メール送信機能だけを使う感じ)

4.4. Worker コーディング

src/index.ts を以下の内容に書き換えてください。

import { EmailMessage } from "cloudflare:email";
import { createMimeMessage, Mailbox } from "mimetext/browser";

export default {
    async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
        try {
            const formData = await request.formData();

            const company = formData.get("company");
            const name = String(formData.get("name"));
            const email = String(formData.get("email"));
            const tel = formData.get("tel");
            const message = formData.get("message");

            const textContent = `【会社名】\n${company}\n\n` +
                `【名前】\n${name}\n\n` +
                `【E-Mail】\n${email}\n\n`+
                `【電話番号】\n${tel}\n\n` +
                `【問い合わせ内容】\n${message}\n\n`;

            const mimeMessage = createMimeMessage();
            mimeMessage.setSender(env.SENDER);
            mimeMessage.setRecipient(env.RECIPIENT);
            mimeMessage.setSubject(`お問い合わせがありました ${company}, ${name}`);
            mimeMessage.addMessage({
                contentType: 'text/plain',
                data: textContent
            });
            mimeMessage.setHeader("Reply-To", new Mailbox({ name, addr: email }));

            await env.SEB.send(new EmailMessage(
                env.SENDER.addr,
                env.RECIPIENT.addr,
                mimeMessage.asRaw()
            ));

            return new Response("Send mail succeeded.", { status: 200 });

        } catch (e) {
            console.log("An exception occurred.", e);
            return new Response(`Send mail failed.`, { status: 500 });
        }
    }
};
  • SEB は「Email Routing バインディング追加」で定義したプロパティ名です。変更した場合は書き換えてください。
  • textContent がメール本文です。お好きなフォーマットに書き換えてください。
  • mimeMessage.setSubject() でメール件名を設定しています。お好みの文面に書き換えてください。

フォームデータの適切なバリデーションとかは各自実装してください。

4.5. Worker デプロイ

コードを書き換えたら以下のコマンドでデプロイしてください。

$ npx wrangler deploy

5. Cloudflare Pages に統合

5.1. サービスバインディングを追加

上記で作成したメール送信 Worker のサービスバインディングをサイトに追加します。

Pages サイト(メールフォームを設置するサイト)のプロジェクトルートにある wrangler.toml に、以下の内容を追加してください。

ファイルがない場合は Cloudflare Dashboard で設定された内容を、 コマンド npx wrangler pages download config [Pages サイト名に置換] を実行することでダウンロードできます。

# ...中略...

[[services]]
binding = "CONTACTMAIL"
service = "[メール送信 Worker の名前に置換]"
environment = "production"

[[env.production.services]]
binding = "CONTACTMAIL"
service = "[メール送信 Worker の名前に置換]"
environment = "production"
  • CONTACTMAIL は Pages Functions コード内でアクセスできるプロパティ名になり、これ以外のお好きな名前に変更できます。

(TypeScript を使用する場合)作成したらコマンド

$ wrangler types --env-interface CloudflareEnv env.d.ts

を実行し、タイプファイル env.d.ts を生成してください。 これは wrangler.toml を編集する度に実行する必要があります。

5.2. Pages Functions 作成

メール送信 Worker を呼び出す Pages Functions を作成します。

まず、Pages サイトのプロジェクトルートに functions フォルダを作成してください。

次に、(TypeScript を使用する場合は)下記の内容で functions/tsconfig.json ファイルを作成してください。

{
  "compilerOptions": {
    "target": "esnext",
    "module": "esnext",
    "lib": [
      "esnext"
    ],
    "types": [
      "@cloudflare/workers-types",
      "../env.d.ts"
    ]
  }
}

次に functions/api/contact.ts ファイルを作成してください。 内容は下記のとおりです。

export const onRequestPost: PagesFunction = async (context) => {
    return context.env.CONTACTMAIL.fetch(context.request);
};
  • CONTACTMAIL は上で定義したバインディングの名前です。
  • onRequestPost でエクスポートするので、POST メソッドのみ受け付ける設定です。
  • コードの内容は Pages Functions にクライアントから渡されたリクエストをそのままメール送信 Worker に横流ししているだけです。

冒頭で述べた通り、Pages Functions に Email Routing を直接バインディングしたいところですが、 今のところそのような事はできません。

5.3. サイトにフォーム設置

上記の Pages Functions は https://your-site.example.jp/api/contact に公開されます (your-site.example.jp は実際のドメインに置き換えてください)

後は、この URL に対してポストするフォームをサイト上に作成すればよいです。

  • この例では Next.js 及び React を使用しています。
  • フォームの処理に react-hook-form を使用しています(npm i react-hook-form
  • CSS フレームワークに Bulma を使用しています(npm i bulma
"use client";
import { useRouter } from "next/navigation";
import { SubmitHandler, useForm } from "react-hook-form";

interface ContactFormData {
    company: string;
    name: string;
    email: string;
    tel: string;
    message: string;
}

// FormData が application/x-www-form-urlencoded のみ受け付けるためのエンコード
function encode(data: ContactFormData) {
    return Object.entries(data)
        .map(([key, value]) => encodeURIComponent(key) + '=' + encodeURIComponent(value))
        .join('&')
}

export default function Home() {
    const {
        register,
        handleSubmit,
        formState: { isSubmitting, isValid }
    } = useForm<ContactFormData>();
    const router = useRouter();

    const submit: SubmitHandler<ContactFormData> = async (values) => {
        return fetch("/api/contact", {
            method: 'POST',
            headers: { "Content-Type": "application/x-www-form-urlencoded" },
            body: encode(values),
        })
            .then((response) => {
                if (!response.ok) {
                    console.error(`${ response.status } ${ response.statusText } ${ response.body }`);
                    alert("エラーにより送信できませんでした。内容をご確認の上、時間をおいてから再度お試しください。");
                    return false;
                } else {
                    router.push("/thanks");
                    return true;
                }
            })
            .catch((error) => {
                alert(error);
                return false;
            });
    };

    return (
        <main>
            <div className="content">
                <h1 className="title is-3">お問い合わせフォーム</h1>
                <form onSubmit={ handleSubmit(submit) }>
                    <div className="field">
                        <label className="label" htmlFor={ 'company' }>
                            貴社名 <span className="has-text-danger is-size-7">※必須</span>
                        </label>
                        <div className="control">
                            <input
                                className="input"
                                type={ 'text' }
                                id={ 'company' }
                                required={ true }
                                disabled={ isSubmitting }
                                { ...register("company", { required: true }) }
                            />
                        </div>
                    </div>
                    <div className="field">
                        <label className="label" htmlFor={ 'name' }>
                            お名前 <span className="has-text-danger is-size-7">※必須</span>
                        </label>
                        <div className="control">
                            <input
                                className="input"
                                type={ 'text' }
                                id={ 'name' }
                                required={ true }
                                disabled={ isSubmitting }
                                { ...register("name", { required: true }) }
                            />
                        </div>
                    </div>
                    <div className="field">
                        <label className="label" htmlFor={ 'email' }>
                            E-Mail <span className="has-text-danger is-size-7">※必須</span>
                        </label>
                        <div className="control">
                            <input
                                className="input"
                                type={ 'email' }
                                id={ 'email' }
                                required={ true }
                                disabled={ isSubmitting }
                                { ...register("email", { required: true }) }
                            />
                        </div>
                    </div>
                    <div className="field">
                        <label className="label" htmlFor={ 'tel' }>
                            お電話番号
                        </label>
                        <div className="control">
                            <input
                                className="input"
                                type={ 'tel' }
                                id={ 'tel' }
                                required={ false }
                                disabled={ isSubmitting }
                                { ...register("tel") }
                            />
                        </div>
                    </div>
                    <div className="field">
                        <label className="label" htmlFor={ 'message' }>
                            お問い合わせ内容 <span className="has-text-danger is-size-7">※必須</span>
                        </label>
                        <div className="control">
                                <textarea
                                    className="textarea"
                                    id={ 'message' }
                                    required={ true }
                                    disabled={ isSubmitting }
                                    { ...register("message", { required: true }) }
                                />
                        </div>
                    </div>
                    <div className="field">
                        <button
                            className={ `button is-link ${ isSubmitting ? "is-loading" : "" }` }
                            type="submit"
                            disabled={ isSubmitting || !isValid }
                        >送信
                        </button>
                    </div>
                </form>
            </div>
        </main>
    );
}

フォームデータの適切なバリデーションとかは各自実装してください。

5.4. サイトをデプロイ

まず、サイトをビルドしてください(例では Next.js のビルド)

$ npm run build

次に、Pages と Pages Functions をデプロイしてください。 以下のコマンドで両方ともデプロイされます。

$ npx wrangler pages deploy

デプロイが終わったら、サイトにアクセスしてメール送信出来るかテストしましょう。

以上で、Cloudflare のフリープランだけで、かつメール送信用の外部サービスを使用せずにメールフォームの実装ができました!

「ローコード」「イージー」「ファスト」と言い張るにはちょいと癖がある気がしますが、 まあ普通に出来るということはお分かりいただけたでしょう・・・。

6. サンプルコード

以下のリポジトリにサンプルコードをアップロードしています。