Notionのデータベースを監視して、Notionを使っていない社外の相手にもメールでリマインドし、相手のワンクリックをNotionに書き戻す——というツール(Kapsel)を個人で作っています。この記事は、その中で技術的に判断が要った箇所を、実装を交えて共有する設計メモです。作者本人が書いています。
技術スタック:Next.js (App Router) / Supabase (Postgres) / Notion 公式API。
全体アーキテクチャ
毎分、2つのジョブが動きます。
-
poll:各ユーザーの監視対象DBをNotion APIで照会 → 期日・ステータス・宛先メールをキャッシュ → ルールを評価して「送るべき通知」を
notificationsテーブルに積む - dispatch:送信予定時刻が来た通知をメール送信する
poll と dispatch を分けているのは、「何を送るか決める」処理と「実際に送る」処理を独立させ、リトライや重複制御をやりやすくするためです。
1. 定期実行を GitHub Actions から pg_cron に移した
最初はGitHub Actionsのスケジュールで叩いていたのですが、無料枠の schedule は強く間引かれ、実測で1時間近く空くことがありました。期日リマインドで1時間ズレは致命的です。
そこで、すでに使っている Supabase の Postgres 上の pg_cron + pg_net から毎分エンドポイントを叩く方式に変えました。新しいサービスを増やさず、追加コストもなく、分単位で安定します。
select cron.schedule('kapsel-poll', '* * * * *', $$
select net.http_post(
url := (select decrypted_secret from vault.decrypted_secrets where name = 'kapsel_base_url') || '/api/cron/poll',
headers := jsonb_build_object(
'Content-Type', 'application/json',
'Authorization', 'Bearer ' || (select decrypted_secret from vault.decrypted_secrets where name = 'kapsel_cron_secret')
),
body := '{}'::jsonb,
timeout_milliseconds := 285000
);
$$);
ポイントは、認証シークレットを Supabase Vault に入れ、ジョブ定義に平文を残さないことです。decrypted_secrets ビュー経由で実行時に読み出し、エンドポイント側は Authorization: Bearer <CRON_SECRET> で検証します。
2. 「完了・入金済みには送らない」をどう実装したか
このツールで一番こだわった仕様です。支払い済み・対応済みの相手に催促が飛ぶ事故は、文面の丁寧さでは防げません。送信判定の段階で構造的に外す必要があります。
やっていることはシンプルで、監視DBの「done とみなすステータス値(done_values)」を設定 → 各ページの is_done を判定 → ルールが skip_if_done なら除外、です。
// lib/notify.ts(通知プランを組む所)
for (const item of eligibleItems(rule, items)) {
if (rule.skip_if_done && item.is_done) continue; // 完了/入金済みはここで落とす
// …送信予定を積む
}
「行き違いでしたらご容赦ください」と本文で謝るのではなく、そもそも対象から外す。ここをシステム側の責務にしたのが設計の主眼でした。
二重送信は、通知を積むときに dedupe_key を持たせ、upsert(..., { ignoreDuplicates: true }) で弾いています。
3. Notionを使わない相手に「安全に」操作させる
社外の相手はNotionアカウントを持ちません。なので、メールに載せた [✅完了] / [😴スヌーズ] のリンクそのものを認証にする設計にしました。ログインもJWTも要りません。
リンクのトークンは、OAuthの state 署名と同じHMAC方式を流用した署名付きペイロードです。
// lib/action-links.ts
export type ActionTokenPayload = {
v: 1;
nid: string; // notification id
uid: string; // 送信元ユーザーid(サーバ側で二重チェック)
act: 'done' | 'snooze';
exp: number; // 有効期限(unix秒・既定7日)
};
// token = base64url(payload) + "." + HMAC_SHA256(payload)
検証は crypto.timingSafeEqual で行い(タイミング攻撃対策)、期限切れ・改竄・uid不一致は弾きます。受信者がボタンを押すと、このトークンを検証したうえで Notion の該当ページのステータスを PATCH し、メール→Notionへの書き戻しが成立します。相手は何もインストールしていません。
なお、配信停止(unsubscribe)リンクだけは、あえて有効期限を付けていません。オプトアウトの導線は恒久的に効かせる必要があるためです(特定電子メール法)。
// 期限あり: アクションリンク(exp を持つ)
// 期限なし: 配信停止リンク(opt-out は永続で有効にする)
4. トークンの守り方
Notionのアクセストークンは、AES-256-GCM で暗号化して保存しています(鍵は環境変数から SHA-256 で導出、12バイトIV+認証タグ)。
// lib/crypto.ts
export function encrypt(plain: string): string {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', key(), iv);
const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return [iv, tag, enc].map((b) => b.toString('base64')).join('.');
}
- OAuthは公式のauthorization codeフロー。
stateは HMAC-SHA256 署名+短命で、CSRF/改竄に対処 - DBは Supabase Postgres で行レベルセキュリティ(RLS) 有効
- Notionの
usersAPI は呼ばず、ユーザー一覧・個人情報は取得しない
ハマったこと・学び
- 無料のスケジューラは"分単位で正確"を期待してはいけない。間引きの実測を取って、pg_cron に逃がした
- 「送らない」を後段(送信直前)でなく通知を積む段階で判定した方が、二重送信やログの一貫性が保ちやすかった
- 署名トークンを"認証そのもの"にすると、ログイン不要のUXと安全性を両立できる。ただし
expとuidのサーバ再検証は必須
作る技術より、正直いま苦労しているのは「届ける」側なのですが、それはまた別の記事で。
※本記事で解説した仕組みは、個人開発中のNotion通知ツール Kapsel(https://getkapsel.com )の実装をもとにしています。