📅

Cloudflare Email Routing で予約完了メールから予定を自動作成する

に公開

今年で 3 回目の mast アドベントカレンダーへの参加になりました。10 周年のアドカレが全日埋まったのもめでたいですね。

2025 年はなんだかんだ 1 年間オンライン英会話をやっていました。英語力が上がったかはさておき一年続いて嬉しいです。

そんなオンライン英会話ですが、レッスンを予約したときに Google カレンダーにイベントを作成するのが少し面倒です。実際、一年間で何度かは登録する時間を間違えてレッスンをすっぽかしました。そのため、今回は Cloudflare Email Routing と D1、Hono を使って予約完了のメールから予定を自動で作成するようにしてみたいと思います。


予約完了メール。ここからうまく日時を抜き出したい。

Cloudflare Email Routing

Cloudflare で管理しているドメインのカスタムメールアドレスを自由に作成し、そのアドレスへメールを受信した際に Cloudflare Worker を利用してプログラムを実行できるサービスです。メールをもとに簡単な処理を行うにはぴったりです。

wrangler.jsonc の作成

まずは wrangler.jsonc を以下のように作成します。compatibility_date は Web コンソールから Email Worker を作成した際に 2000-01-01 だったのでそのまま使っていますがもっと新しい日付でも多分動くと思います。d1_databases のところは最近事前に D1 データベースを Web コンソールから作成しなくても自動で作成してくれるようになったので ID を記載しなくて大丈夫です。

{
  "$schema": "./node_modules/wrangler/config-schema.json",
  "name": "cambly-reservation-ics",
  "main": "src/main.ts",
  "compatibility_date": "2000-01-01",
  "compatibility_flags": [
    "nodejs_compat"
  ],
  "workers_dev": true,
  "preview_urls": true,
  "d1_databases": [
    {
      "binding": "DB",
      "database_name": "cambly_reservation_ics",
      "migrations_dir": "migrations"
    }
  ],
  "observability": {
    "enabled": true,
    "head_sampling_rate": 1
  }
}

パッケージインストール

必要なパッケージをインストールします。

npm add -D wrangler
npm add hono ical-generator postal-mime
  • wrangler: デプロイや開発時の操作用
  • postal-mime: メールのパース
  • ical-generator: ics ファイルの生成
  • hono: アクセス時に ics ファイルを返す Web サーバ用

本体

src/main.ts に以下のような処理を行うプログラムを実装します。

  • メールを受け取ったときにそれがオンライン英会話のサービスからのメールだったらメール本文から日時をパースして D1 に保存
  • 特定のエンドポイントへのアクセスがあったら D1 のデータから ics ファイルを作成して返す
import PostalMime from "postal-mime";
import ical, { ICalCalendarMethod } from "ical-generator";
import { Hono } from "hono";

const SENDER_ADDRESS = "info@cambly.com";

type ReservationRow = {
  id: number;
  starts_at: string;
  ends_at: string;
  title: string;
  created_at: string;
};

const parseDbDate = (v?: string) => {
  if (!v) return null;
  const d = new Date(
    v.includes(" ") && !v.includes("T") ? v.replace(" ", "T") + "Z" : v,
  );
  return Number.isNaN(d.getTime()) ? null : d;
};

const to24h = (hh: string, ap: string) => {
  let h = +hh;
  if (ap === "午前") return h === 12 ? 0 : h;
  return h === 12 ? 12 : h + 12;
};

const parseJstRangeFromBody = (body: string) => {
  const m = body.match(
    /(\d{4})(\d{2})(\d{2})[月火水木金土日]曜日[\s\S]*?(\d{2}):(\d{2}) (午前|午後)\s*-\s*(\d{2}):(\d{2}) (午前|午後)/,
  );
  if (!m) return null;

  const [, y, mo, d, sh, sm, sap, eh, em, eap] = m;
  const year = +y, month = +mo, day = +d;

  const start = new Date(
    Date.UTC(year, month - 1, day, to24h(sh, sap) - 9, +sm),
  );
  let end = new Date(Date.UTC(year, month - 1, day, to24h(eh, eap) - 9, +em));

  if (end <= start) end = new Date(end.getTime() + 86400000);
  return { start, end };
};

const app = new Hono<{ Bindings: Cloudflare.Env }>().get(
  "/:calendar_id",
  async (c) => {
    if (c.req.param("calendar_id") !== c.env.CALENDAR_ID) {
      return c.json(null, 401);
    }

    const { results } = await c.env.DB
      .prepare(
        "SELECT id, starts_at, ends_at, title, created_at FROM reservations ORDER BY starts_at ASC",
      )
      .all<ReservationRow>();

    const now = new Date();

    const cal = ical({
      name: "Cambly Reservations",
      method: ICalCalendarMethod.PUBLISH,
    });

    for (const r of results ?? []) {
      const start = new Date(r.starts_at);
      const end = new Date(r.ends_at);
      if (
        Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())
      ) continue;

      cal.createEvent({
        id: `cambly-${r.id}@cambly-reservation-ics`,
        stamp: parseDbDate(r.created_at) ?? now,
        start,
        end,
        summary: r.title,
      });
    }

    return c.text(cal.toString(), 200, {
      "Content-Type": "text/calendar; charset=utf-8",
      "Content-Disposition": 'attachment; filename="cambly.ics"',
      "Cache-Control": "no-store",
    });
  },
);

export default {
  fetch: app.fetch,

  async email(message, env) {
    const raw = await new Response(message.raw).arrayBuffer();
    const mail = await PostalMime.parse(raw);

    if (mail.from?.address !== SENDER_ADDRESS) {
      return;
    }

    const body = (mail.html ?? mail.text)?.replaceAll(/\r\n/g, "\n");
    if (!body) {
      return;
    }

    const range = parseJstRangeFromBody(body);
    if (!range) {
      return;
    }

    await env.DB
      .prepare(
        "INSERT INTO reservations (starts_at, ends_at, title) VALUES (?1, ?2, ?3) " +
          "ON CONFLICT(starts_at, ends_at) DO UPDATE SET title = excluded.title",
      )
      .bind(range.start.toISOString(), range.end.toISOString(), "cambly")
      .run();
  },
} satisfies ExportedHandler<Cloudflare.Env>;

保存したら、 npx wrangler deploy でデプロイします。すると、Web コンソールから Worker と D1 データベースが生成されていることが確認できます。このときに D1 データベースの ID を wrangler.jsonc に追記します。

  ...
  "d1_databases": [
    {
      "binding": "DB",
      "database_name": "cambly_reservation_ics",
+     "database_id": "<DATABASE_ID>",
      "migrations_dir": "migrations"
    }
  ],
  ...

D1 のマイグレーション

npx wrangler d1 migrations create DB create_reservations を実行すると migrations/0001_create_reservations.sql というファイルができるので、以下のように編集します。

CREATE TABLE IF NOT EXISTS reservations (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  starts_at TEXT NOT NULL,
  ends_at TEXT NOT NULL,
  title TEXT NOT NULL,
  created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now')),
  UNIQUE (starts_at, ends_at)
);

保存したら、 npx wrangler d1 migrations apply DB --remote を実行してマイグレーションを適用します。

メールアドレスの設定

Cloudflare の Web コンソールの Email Routing の設定から、「Create custom address」をクリックして適当なアドレスを作成、Action のところを Send to a Worker にして、 Destination で先ほど作成した Worker を選択します。

保存したら、Gmail 等の設定から転送したいメールをフィルタして作成したアドレスに自動転送するように設定します。

(おまけ) メール転送

オンライン英会話サービスには Proton Mail で取得したメールアドレスを登録していたのですが、メールの自動転送は有料かつ、転送先アドレスに届く承認 URL へアクセスする必要がありました。(これに手こずって投稿が日付を跨いでしまいました🥲)

最終的には諦めて以下のような Webhook を叩く関数を作成し、メールの本文から正規表現で URL らしいものを抜き出して Webhook で Discord に転送、承認 URL っぽいものを選んでアクセスして承認することで解決しました。

const sendWebhook = async (msg: string) => {
  await fetch(
    `https://discord.com/api/webhooks/...`,
    {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        content: msg,
      }),
    },
  );
};

完成

最後に、npx wrangler secret put CALENDAR_ID を実行して、長めで推測されづらい適当な文字 (UUIDなど) をセットしたら完成です!

https://cambly-reservation-ics.<USERNAME>.workers.dev/<CALENDAR_ID> にアクセスすると ics ファイルが返ってくるようになったと思います。Google カレンダーなどに登録して使えます。実際に、予約してしばらくすると以下のように反映されました。

これでもうカレンダーの登録忘れですっぽかすことはなくなりそうです。めでたしめでたし。オンライン英会話でなくとも、メールを元に何かを処理したいときは Cloudflare Email Routing + Cloudflare Workers + Cloudflare D1 の構成が無料かつ手軽なのでとてもおすすめです。

それでは良いお年を!

Discussion