0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Notionの期日を「社外にも・完了済みには送らず」メール通知する仕組みを個人開発した設計メモ(Next.js / Supabase / pg_cron)

0
Posted at

Notionのデータベースを監視して、Notionを使っていない社外の相手にもメールでリマインドし、相手のワンクリックをNotionに書き戻す——というツール(Kapsel)を個人で作っています。この記事は、その中で技術的に判断が要った箇所を、実装を交えて共有する設計メモです。作者本人が書いています。

技術スタック:Next.js (App Router) / Supabase (Postgres) / Notion 公式API

全体アーキテクチャ

毎分、2つのジョブが動きます。

  1. poll:各ユーザーの監視対象DBをNotion APIで照会 → 期日・ステータス・宛先メールをキャッシュ → ルールを評価して「送るべき通知」を notifications テーブルに積む
  2. dispatch:送信予定時刻が来た通知をメール送信する

polldispatch を分けているのは、「何を送るか決める」処理と「実際に送る」処理を独立させ、リトライや重複制御をやりやすくするためです。

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の users API は呼ばず、ユーザー一覧・個人情報は取得しない

ハマったこと・学び

  • 無料のスケジューラは"分単位で正確"を期待してはいけない。間引きの実測を取って、pg_cron に逃がした
  • 「送らない」を後段(送信直前)でなく通知を積む段階で判定した方が、二重送信やログの一貫性が保ちやすかった
  • 署名トークンを"認証そのもの"にすると、ログイン不要のUXと安全性を両立できる。ただし expuid のサーバ再検証は必須

作る技術より、正直いま苦労しているのは「届ける」側なのですが、それはまた別の記事で。


※本記事で解説した仕組みは、個人開発中のNotion通知ツール Kapsel(https://getkapsel.com )の実装をもとにしています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?