Notionで請求や案件のステータス管理をしていて、「期日を過ぎたのに気づかなかった」と「もう入金済みの相手にリマインドを送ってしまった」の両方を経験したことはないでしょうか。前者は督促の実装が甘いと起きる事故で、後者は逆に実装が雑だと起きる事故です。この記事は、Notionのデータベースを監視して外部にメールでリマインドを送る仕組み(個人開発しているKapselというツールの実装)をもとに、この二つの事故をどう構造的に防ぐかを整理した設計ノートです。
この記事の範囲
対象は「Notionの期日・ステータスを監視して、条件に合う対象だけにメールでリマインドを送る」タイプの督促バッチです。技術スタックは Next.js (App Router) / Supabase (Postgres) / Notion 公式API / Resend / pg_cron。動くコードそのものというより、判断の置き場所(どこで何を止めるか)に焦点を当てています。
督促通知の失敗は二方向に分かれる
督促・リマインドの実装ミスは、だいたい次の二軸のどちらかに落ちます。
| 失敗の方向 | 具体例 | 原因になりやすい実装 |
|---|---|---|
| 送り漏れ | 期日超過に気づかず放置される | ポーリング条件が緩い/スケジュールの間隔が粗い |
| 送りすぎ | 入金済み・完了済みの相手に届く/同じ相手に何通も届く | 停止条件がない/冪等性がない |
厄介なのは、この二つはだいたいトレードオフの関係にあることです。「送り漏れが怖いから頻度を上げる」と「送りすぎ」のリスクが上がり、「送りすぎが怖いから条件を厳しくする」と「送り漏れ」のリスクが上がります。なので、頻度を上げても送りすぎない設計を、別々の仕組みで両立させる必要があります。
送り漏れを防ぐ:ポーリング条件をどこで絞るか
まず「気づけていない」を防ぐには、対象を漏れなく拾えるクエリと、拾う頻度の両方が要ります。
Notion APIのデータベースクエリは、フィルタをこちらで組み立てて渡せます。「期日を過ぎていて、かつステータスが完了系ではない」を、アプリ側で全件取得してから絞るのではなく、クエリの条件として渡すのが基本です。
{
"filter": {
"and": [
{ "property": "支払期日", "date": { "before": "2026-08-18" } },
{ "property": "ステータス", "select": { "does_not_equal": "入金済み" } }
]
}
}
全件取得してからアプリ側でフィルタする書き方でも動きはしますが、対象DBが大きくなるとページングが増え、レートリミットにも近づきます。絞れる条件はできるだけクエリ側に寄せておくと安全です。
頻度については、無料のスケジューラ(GitHub Actionsのscheduleなど)は間引かれて実行間隔が安定しないことがあります。私は最終的にSupabaseのpg_cronで毎分ポーリングする構成に落ち着きました。期日ベースのリマインドで「1時間近くズレる」は致命的なので、毎分に近い頻度で監視できるかどうかは、送り漏れの許容度を決める前提条件になります。
送りすぎを防ぐ(1): 停止条件を送信ロジックの一番先頭に置く
ここがこのツールで一番こだわった部分です。「支払い済みの相手に督促が飛ぶ」事故は、文面をどれだけ丁寧にしても防げません。送信の可否を決める判定の、一番最初の分岐にしておく必要があります。
// 通知対象を組み立てる処理の一部(概念コード)
const doneValues = new Set(rule.doneValues); // 例: ["入金済み", "完了"]
function isEligibleForReminder(item: NotionItem, today: string): boolean {
if (doneValues.has(item.status)) return false; // 停止条件を最優先で評価
if (!item.dueDate || item.dueDate >= today) return false; // 期日未到来はそもそも対象外
return true;
}
ポイントは「本文で謝る」のではなく「対象から機械的に外す」ことです。停止条件をif文の後ろの方や、送信直前の付け足しにすると、途中の分岐を書き換えたときにうっかり外れます。一番先頭・一番目立つ場所に置くのが安全です。
もう一つ効くのが、対象を取得したタイミングと、実際に送信するタイミングの間にタイムラグがあることです。ポーリングで拾ってからメール送信キューに積むまでの間に、相手が入金してステータスを更新しているかもしれません。なので、取得時の状態を信用しきらず、送信の直前にもう一度対象ページを取得して、そこでもisEligibleForReminder相当の判定を通す二段構えにしています。一度の判定で終わらせないのが、送りすぎを防ぐうえで効きました。
送りすぎを防ぐ(2): 送信記録で冪等性を担保する
停止条件だけでは「同じ対象に二重で送ってしまう」事故は防げません。ポーリングが毎分走る構成だと、何もしなければ同じ行が毎回条件に一致し続けて送信されます。ここは送信記録テーブルとDB側の一意制約で防ぐのが定石です。
create table notifications (
id uuid primary key default gen_random_uuid(),
page_id text not null,
reminder_type text not null,
date_bucket date not null, -- 「その日」や「N日おきの窓」を表す粒度
sent_at timestamptz,
created_at timestamptz not null default now(),
unique (page_id, reminder_type, date_bucket)
);
const { error } = await supabase
.from('notifications')
.insert({ page_id: item.id, reminder_type: 'overdue', date_bucket: today })
.select()
.single();
if (error?.code === '23505') {
// 一意制約違反 = 同じ対象への送信予定がすでに存在する。ここで弾いて重複を防ぐ。
continue;
}
date_bucketの粒度をどう取るかで、再通知のポリシーを状態を持たずに表現できます。「毎日1通まで」なら日付そのもの、「3日おきに1通」なら日数を丸めたバケットにする、といった具合です。アプリ側のロックやSetで頑張るより、DBの一意制約に任せた方が、複数のジョブが同時に走っても壊れにくいと感じています。
送信記録は事故防止だけでなく、「言った・言わない」を防ぐ運用面でも効きます。相手から「そんなメール来てない」と言われたときに、いつ・どの条件で・何を送ったかを追える状態にしておく価値は、実装コスト以上にありました。
送りすぎを防ぐ(3): 受信者の操作をNotionに双方向で反映する
もう一つの送りすぎ経路が、「相手はもう対応したのに、こちらのNotionが更新されていない」ケースです。相手が実際に入金・対応していても、Notion側のステータスが古いままだと、次のポーリングでまた対象に入ってきます。
これに対しては、メールに載せたボタン(完了・スヌーズ)を受信者がワンクリックすると、その結果をNotionの該当ページに書き戻す設計にしています。相手はNotionアカウントを持たないので、ログインなしで安全に操作させる部分にはそれなりに実装コストがかかりました(署名付きトークンでの検証など)が、ここは別の記事でまとめているので、この記事では「対応済みの反映をNotion側に戻す経路がある」ことだけ触れておきます。書き戻しがないと、いくら停止条件と冪等性を作り込んでも、Notion側の情報が古いままなら意味がありません。
送っても届かなければ意味がない
ここまでは「送るべきでない対象に送らない」話でしたが、逆に「送るべき対象に、ちゃんと届く」ことも同じくらい重要です。送信元ドメインでSPF・DKIM・DMARCを揃えていないと、督促メールが迷惑メール扱いされて、受信者に一度も見られないまま「送り漏れ」と同じ結果になります。ここは実装というより設定の話ですが、認証周りは公開前に一度Gmailの受信でDMARCレポートを確認しておくと安心です。
まとめ
督促・リマインドの実装は、機能の有無より「どこで何を止めるか」の設計が効きます。
| 目的 | 対策 | 実装場所 |
|---|---|---|
| 送り漏れを防ぐ | フィルタをクエリ側に寄せる/ポーリング頻度を上げる | Notion APIのクエリ・スケジューラ |
| 送りすぎを防ぐ | 停止条件を判定の先頭に置く/送信直前に再確認 | 通知対象を組み立てる処理 |
| 送りすぎを防ぐ | 送信記録+一意制約で冪等性を担保 | DBスキーマ |
| 送りすぎを防ぐ | 受信者の操作をNotionに書き戻す | アクションリンクの検証処理 |
| 届かない事故を防ぐ | SPF/DKIM/DMARCを揃える | DNS設定 |
「送りすぎ」と「送り漏れ」は別々の対策が要る、というのがこの記事の要点です。片方だけ作り込んでも、督促の仕組みとしては半分しか完成しません。
この設計は、Notionの期日・ステータスを監視して社外の相手にもメールでリマインドする個人開発ツール Kapsel の実装をもとにしています。フリーランス向けの請求・入金確認まわりで似た課題を整理した記事はこちらです。