Cloudflare Email RoutingとWorkersでメール受信をPushoverに通知する

特定のアドレスに届くメールを見逃したくなかったのですが、Gmailの通知は他のメールに埋もれて気づけませんでした。そこで、Cloudflare Email Routingで受けたメールをWorkerに渡し、PushoverのAPIで「確認ボタンを押すまで鳴り続ける」通知を送るようにしました。Workerは50行ちょっとで済んでいます。

普段の受信箱にもそのまま転送しているので、メール自体はいつも通りGmailで読めます。Pushoverは「届いたことに気づく」ためだけに使っている形です。

構成は以下です。

  • ドメインのDNSはCloudflareで管理している
  • Cloudflare Email Routing(受信)+ Email Worker(通知と転送)
  • Pushover(iPhoneにアプリを入れています)
  • wrangler 4.152.0、メールのパースに postal-mime 4.1.4

全体の流れ

alert@example.comのような専用アドレスにメールが届くと、Email Routingがそれを受けてWorkerのemailハンドラを呼びます。Workerの中では2つのことをしています。

  1. Gmailなど普段の受信箱へ転送する
  2. 件名と差出人をPushoverのAPIに投げて、スマホを鳴らす

Pushoverには優先度があって、priority=2(Emergency)にすると、アプリで確認(Acknowledge)するまで一定間隔で再通知してくれます。普通のプッシュ通知だと1回鳴って終わりなので、気づかないことがあります。これが今回いちばん欲しかった機能です。

Pushoverのトークンを用意する

Pushoverにアカウントを作ると、ダッシュボードにUser Keyが表示されます。これが通知の送り先です。

送る側のトークンは、Create an Application/API Tokenからアプリケーションを登録すると発行されます。名前は何でもよくて、自分はnoticeにしました。

スマホ側にはPushoverのアプリを入れてログインしておきます。アプリは試用期間のあとに買い切りの購入が必要です。

Workerのプロジェクトを作る

ファイルはこれだけです。

notice/
├── package.json
├── wrangler.toml
├── .dev.vars        # ローカル用の秘密情報(gitには入れない)
└── src/index.js
{
  "name": "notice",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "wrangler dev",
    "deploy": "wrangler deploy"
  },
  "dependencies": {
    "postal-mime": "4.1.4"
  },
  "devDependencies": {
    "wrangler": "4.152.0"
  }
}

wrangler.toml

name = "notice"
main = "src/index.js"
compatibility_date = "2026-10-01"
# メール処理専用のため workers.dev の URL は公開しない
workers_dev = false
preview_urls = false

[observability]
enabled = true

[vars]
# 通常の受信箱への転送先。Email Routing で検証済みのアドレスにする。空なら転送しない
FORWARD_TO = "you@gmail.com"
# 再通知の間隔(秒, 30 以上)と継続時間(秒, 10800 以下)
PUSHOVER_RETRY = "30"
PUSHOVER_EXPIRE = "3600"
# 通知音。https://pushover.net/api#sounds の名前を指定する
PUSHOVER_SOUND = "persistent"

このWorkerはメールを受けるだけなので、workers_dev = falseとpreview_urls = falseでHTTPのURLを公開しないようにしています。URLがあってもfetchハンドラが無いので何もできませんが、使わない入口は閉じておきたかったので明示しました。

[observability]を有効にしておくと、console.logの出力をダッシュボードのログで後から見られます。通知が来なかったときに原因を追うのに使います。

再通知の設定は、30秒おきに最大1時間鳴らす設定にしています。PUSHOVER_SOUNDのpersistentは長めに鳴る音で、Emergencyと組み合わせるとかなりうるさいです。気づくのが目的なのでこれでいいと思っています。

src/index.js

import PostalMime from "postal-mime";

const PUSHOVER_API = "https://api.pushover.net/1/messages.json";
// Pushover の上限: title 250 文字, message 1024 文字
const TITLE_MAX = 250;
const MESSAGE_MAX = 1024;
// Emergency 優先度の制約: retry は 30 秒以上, expire は 10800 秒以下
const RETRY_MIN = 30;
const EXPIRE_MAX = 10800;

export default {
  async email(message, env, ctx) {
    // 転送が失敗しても通知は送るため、例外は握ってログに残す
    if (env.FORWARD_TO) {
      try {
        await message.forward(env.FORWARD_TO);
      } catch (e) {
        console.error("forward failed", { error: String(e) });
      }
    }

    // 件名は MIME エンコードされている場合があるためパースして取り出す
    const email = await PostalMime.parse(message.raw);
    const subject = email.subject || "(no subject)";

    await notify(env, {
      title: subject.slice(0, TITLE_MAX),
      message: `From: ${message.from}\nTo: ${message.to}`.slice(0, MESSAGE_MAX),
    });
  },
};

async function notify(env, { title, message }) {
  const res = await fetch(PUSHOVER_API, {
    method: "POST",
    body: new URLSearchParams({
      token: env.PUSHOVER_TOKEN,
      user: env.PUSHOVER_USER,
      title,
      message,
      priority: "2", // Emergency (最高レベル): Acknowledge されるまで再通知
      retry: String(clamp(env.PUSHOVER_RETRY, 30, RETRY_MIN, EXPIRE_MAX)),
      expire: String(clamp(env.PUSHOVER_EXPIRE, 3600, RETRY_MIN, EXPIRE_MAX)),
      sound: env.PUSHOVER_SOUND || "persistent",
    }),
  });
  if (!res.ok) {
    // レスポンスには token が含まれないので本文をそのまま記録してよい
    console.error("pushover failed", { status: res.status, body: await res.text() });
    return;
  }
  console.log("pushover sent");
}

// 範囲外の値で API が 400 を返し通知が届かなくなるのを防ぐため丸める
function clamp(value, fallback, min, max) {
  const n = Number.parseInt(value, 10);
  if (Number.isNaN(n)) return fallback;
  if (n < min || n > max) console.warn("value clamped", { value, min, max });
  return Math.min(Math.max(n, min), max);
}

いくつか補足です。

件名のデコード:message.headers.get("subject")でも件名は取れますが、日本語の件名は=?UTF-8?B?44OG44K544OI6YCa55+l?=のようなMIMEエンコードのまま返ってきます。これを通知のタイトルにすると読めないので、postal-mimeで本文ごとパースしてsubjectを取り出しています。

転送を先にやる:転送が失敗しても通知は飛ばしたいので、message.forward()はtry/catchで囲んでログだけ残しています。逆に、通知が失敗しても転送はされているので、メール自体が消えることはありません。

retryとexpireの丸め:PushoverのEmergency優先度は、retryが30秒以上、expireが10800秒(3時間)以下という制約があります。範囲外の値を送るとAPIがエラーを返して、通知そのものが届きません。設定値を間違えたせいで肝心のときに鳴らない、というのが一番困るので、範囲内に丸めて警告ログを出すようにしました。

ローカルで試す

秘密情報は.dev.varsに書きます。.gitignoreに入れておくのを忘れずに。

PUSHOVER_TOKEN=xxxxxxxx
PUSHOVER_USER=xxxxxxxx
# ローカル試験では短めにして鳴りっぱなしを避ける
PUSHOVER_EXPIRE=300

PUSHOVER_EXPIREを短くしているのは、試すたびに1時間鳴り続けるとさすがに困るからです。確認ボタンを押せば止まりますが、何度も試すと面倒でした。

テスト用のメールをtest-mail.emlとして用意します。件名はわざとMIMEエンコードにしています(中身は「テスト通知」です)。

From: sender@example.com
To: alert@example.com
Subject: =?UTF-8?B?44OG44K544OI6YCa55+l?=
Message-ID: <test-1@example.com>
Content-Type: text/plain; charset=UTF-8

test body

npm run devでWorkerを起動して、別のターミナルから/cdn-cgi/handler/emailにPOSTするとemailハンドラが呼ばれます。

npm install
npm run dev
curl -X POST 'http://localhost:8787/cdn-cgi/handler/email?from=sender@example.com&to=alert@example.com' \
  --data-binary @test-mail.eml

これでスマホに「テスト通知」というタイトルの通知が来れば成功です。

デプロイする

本番用のトークンはwrangler secretで登録します。wrangler.tomlの[vars]に書くとリポジトリに残ってしまうので、トークンとUser Keyだけはこちらにしています。

npx wrangler secret put PUSHOVER_TOKEN
npx wrangler secret put PUSHOVER_USER
npm run deploy

Email RoutingでWorkerにつなぐ

最後にCloudflareのダッシュボードで、メールをWorkerに流す設定をします。

  1. 対象ドメインのメール → Email Routingを有効にする(MXレコードなどは自動で追加されます)
  2. 宛先アドレスに転送先(FORWARD_TOに書いたGmailなど)を追加して、届いた確認メールで検証する
  3. ルーティングルールでカスタムアドレス(例: alert@example.com)を作り、アクションを「Workerに送信」、送信先をnoticeにする

2の検証を忘れると、message.forward()が失敗します。Worker側では転送の失敗を握りつぶして通知だけ送るようにしているので、「通知は来るのにGmailに届かない」という状態になります。そういうときはObservabilityのログにforward failedが出ているはずです。

設定が終わったら、普段のメールアドレスからalert@example.com宛てに送ってみて、スマホが鳴り続けること、Gmailにも届いていることを確認すれば完了です。

まとめ

  • Pushoverでアプリケーションを登録して、API TokenとUser Keyを用意する
  • Email Workerのemailハンドラで、message.forward()による転送とPushover APIへの通知を両方やる
  • 日本語の件名はMIMEエンコードされているので、postal-mimeでパースしてから使う
  • priority=2にすると確認するまで鳴り続ける。retryとexpireの範囲外の値は通知自体が失敗するので丸めておく
  • Email Routingのルールでカスタムアドレスの送信先をWorkerにする。転送先のアドレスは事前に検証が必要

Gmailの通知だけだと見逃していたメールに、これで確実に気づけるようになりました。

参考: