← 返回博客

Supabase pg_cron 定时 Edge Function 邮件 + Rovela(2026):Vault 与按窗口的幂等键

把 Supabase Cron(pg_cron)与 pg_net 接到会经 Rovela POST /emails 发摘要的 Edge Function:Vault 存调用凭证、Edge Function secrets 存 sending_access,以及按窗口的 Idempotency-Key。不是 Auth/SMTP——只讲定时 Job。

更新于 2026年11月5日
Supabase pg_cron 定时 Edge Function 邮件 + Rovela(2026):Vault 与按窗口的幂等键

Supabase Cron(pg_cron)负责调度 —— Rovela 负责投递邮件

Supabase Cron 是建立在 pg_cron 扩展上的 Postgres 模块。你可以用经典的五段 cron(在受支持的 Postgres 版本上也可用 30 seconds 这类亚分钟形式)调度周期性 Job,然后执行 SQL 片段、数据库函数、HTTP 请求,或唤醒 Supabase Edge Function。把 Cron 和 pg_net 搭配使用,就能按节奏从 SQL 里 net.http_post——包括 POST 到 Edge Function,由它去发信。

这种形态适合摘要、运维日报、未付发票提醒等正文依赖触发时刻实时 Postgres 状态的事务邮件——注册数、失败 webhook、试用到期——而不是可以提前几小时用 Rovela scheduled_at 冻住的内容。

当 Edge Function(或其他服务端)调用 POST https://api.rovela.dev/emails 时,Rovela 负责入队、DKIM 签名与投递(Send Email API、Quick Start、Authentication)。Supabase 负责 Cron 调度、pg_net 调用、存放函数调用凭证的 Vault,以及存放 sending_access 的 Edge Function secrets。你的函数负责聚合,以及按调度窗口稳定的 Idempotency-Key。Rovela 只负责投递。

本指南把 Supabase Cron(pg_cron)→ Edge Function → Rovela 串起来,做到:

  1. 把 Edge Function 调用凭证放进 Supabase Vault(不要以明文写进公开 migration)
  2. 把 ROVELA_API_KEY(sending_access)、MAIL_FROM、收件人放进 Edge Function secrets——绝不进浏览器、绝不进 Git
  3. 用 cron.schedule + net.http_post(或 Dashboard 的「Edge Function」Job 目标)按摘要节奏调度
  4. 按当前窗口从 Postgres 聚合摘要 / 报告
  5. 经 Rovela 发送,并使用按窗口作用域的 Idempotency-Key
  6. 在 Cron 重试、重叠触发、手动重跑时,仍不重复发信

Rovela 不会创建 Cron Job、启用 pg_cron / pg_net,也不会管理 Vault / Edge Function secrets。 Supabase 负责 schedule → HTTP 调用 → 函数运行;你的函数负责查询 → 映射字段 → POST /emails;Rovela 负责投递。

这不是 Auth / 自定义 SMTP 路径。那条线在 Supabase + Rovela 事务邮件(OTP、魔法链接、Auth hooks)里单独讲。本文只讲定时摘要 / 报告 Job。

与下列文章区分:

Supabase Cron 做什么,Rovela 发什么

关注点归属
五段 cron(或受支持的亚分钟 N seconds)+ Job 名Supabase Cron / pg_cron(Cron、quickstart)
net.http_post 到 Edge Function URLpg_net(pg_net、schedule functions)
项目 URL + 调用用的 secretSupabase Vault
Edge Function 运行时 secrets(ROVELA_API_KEY 等)Edge Function secrets(Dashboard 或 supabase secrets set)
按窗口查询 Postgres你的 Edge Function(service role / SUPABASE_DB_URL / SQL)
渲染 HTML + text你的函数
POST /emails、DKIM、队列、退信Rovela
Idempotency-Key = supabase-cron-daily-digest:{yyyy-mm-dd}(或小时 / 分钟桶)你的函数

Rovela 返回 200 表示已接受并入队,不等于「已经进收件箱」(Quick Start)。

塑造发信 Job 的 Supabase Cron + Edge Function 约束

按官方文档,把这些当作硬要求:

  • 启用扩展。 Cron 依赖 pg_cron;从 SQL 调 Edge Function 需要 pg_net。可在 Integrations → Cron 或用 cron.schedule 建 Job(Cron)。
  • Job 名区分大小写且创建后不可改。 同名再建会覆盖/ upsert(quickstart)。
  • 调用凭证优先放 Vault。 用 vault.create_secret 存 project_url 和 secret key(不要用 publishable key),再在调度 SQL 里读 vault.decrypted_secrets(schedule functions)。
  • 不要把 ROVELA_API_KEY 写进 Cron SQL / pg_net body。 pg_net 会把请求头(含已解密 secret)写进 net.http_request_queue 直到发出——能登录库的角色可能读到(schedule functions 警示)。Rovela key 只放在 Edge Function secrets,由 Deno 去调 Rovela。
  • 为调度路径关闭 JWT 校验(或使用 secret auth)。 调度调用把 secret 放在 apikey 头;按文档对 scheduled Edge Function 使用 verify_jwt 关闭 + auth: 'secret'(或等价的服务间调用方式)(schedule functions)。
  • Job 保持短小。 Supabase 建议并发 Job 不宜超过约 8 个,单个 Job 不宜超过约 10 分钟(Cron)。查询 + 一次 HTTPS 发送(或小批量)后返回。
  • 查看运行记录。 cron.job_run_details 记录历史;需另建清理 Job——不会自动清理(quickstart)。
  • 密钥只留在服务端。 生产发信优先 Rovela sending_access——绝不要放进浏览器包或公开 Git(Authentication)。
  • 尽量别走出站 SMTP。 优先 443 上的 HTTPS POST /emails(Email API vs SMTP)。

何时用 Supabase Cron + POST /emails(而不是 scheduled_at)

在以下情况用 Cron → Edge Function → 实时发送:

  • HTML 必须在触发时刻包含 Postgres 里的最新指标
  • 应用已在 Supabase,希望调度紧挨数据(不必再买一套调度产品)
  • 五段 cron(或可用的亚分钟)已足够
  • 发给运维 / 创始人 / 运行时查到的客户地址的一封(或少量)事务邮件

当正文已经正确、只需要「稍后再投」且不必跑 Job 时,用 Rovela scheduled_at(定时发送邮件)。Cron 与 scheduled_at 解决不同问题;本文是 Supabase Cron 路径。

Auth 生命周期邮件请看 Supabase 事务邮件 API。

前置条件

  1. Rovela 上有已验证发信域名。未验证的 from 会 400(Quick Start)。
  2. 形如 re_… 的 API key。Edge Function 优先用 sending_access(Authentication)。
  3. 具备 Cron(pg_cron)与 pg_net 的 Supabase 项目,以及创建 Edge Function / secrets 的权限。
  4. Edge Function secrets:ROVELA_API_KEY、MAIL_FROM、DIGEST_TO(可选 DIGEST_KIND)。
  5. Vault secrets:project_url 与供 net.http_post 使用的 invoke secret_key。

把调用凭证放进 Vault

执行一次(SQL Editor 或 migration)。把 project ref 与 secret key 换成你自己的——优先 secret key,不要用 publishable key(schedule functions):

select vault.create_secret(
  'https://YOUR_PROJECT_REF.supabase.co',
  'project_url',
  'Supabase API URL for Cron → Edge Function invokes'
);

select vault.create_secret(
  'YOUR_SUPABASE_SECRET_KEY',
  'secret_key',
  'Secret key used as apikey when Cron invokes Edge Functions'
);

密钥轮换用 vault.update_secret(Vault)。严格限制谁能 SELECT vault.decrypted_secrets。

给 Rovela 配置 Edge Function secrets

在生产设置 secrets(Dashboard → Edge Function Secrets,或 CLI)。名称不能以 SUPABASE_ 开头(secrets):

supabase secrets set \
  ROVELA_API_KEY=re_your_sending_access_key \
  MAIL_FROM='Acme Reports <reports@mail.example.com>' \
  DIGEST_TO='founders@example.com' \
  DIGEST_KIND=daily

本地把同样的键放进已 gitignore 的 supabase/functions/.env,用 supabase start / supabase functions serve 加载。生产发信优先 sending_access——绝不要 full_access(Authentication)。绝不要把 API key 打进日志。新 secret 部署后的函数可立即读到,无需重新 deploy(secrets)。

Edge Function:按窗口的 Idempotency-Key + POST /emails

创建函数:

supabase functions new daily-digest

示例 supabase/functions/daily-digest/index.ts:

// supabase/functions/daily-digest/index.ts
import { createClient } from 'npm:@supabase/supabase-js@2'

const ROVELA_URL = 'https://api.rovela.dev/emails'

function requireEnv(name: string): string {
  const v = Deno.env.get(name)
  if (!v) throw new Error(`missing env ${name}`)
  return v
}

function utcDayKey(d = new Date()): string {
  return d.toISOString().slice(0, 10) // YYYY-MM-DD
}

function utcHourKey(d = new Date()): string {
  return d.toISOString().slice(0, 13) // YYYY-MM-DDTHH
}

function windowKey(kind: string): string {
  if (kind === 'hourly') return `supabase-cron-hourly-ops:${utcHourKey()}`
  if (kind === 'fifteen') {
    const d = new Date()
    const bucket = Math.floor(d.getUTCMinutes() / 15) * 15
    const hh = String(d.getUTCHours()).padStart(2, '0')
    const mm = String(bucket).padStart(2, '0')
    return `supabase-cron-fifteen-ops:${utcDayKey()}T${hh}:${mm}`
  }
  return `supabase-cron-daily-digest:${utcDayKey()}`
}

async function buildDailyDigest(day: string) {
  const url = Deno.env.get('SUPABASE_URL')!
  const serviceKey =
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ??
    (() => {
      try {
        const keys = JSON.parse(Deno.env.get('SUPABASE_SECRET_KEYS') ?? '{}')
        return keys.default as string | undefined
      } catch {
        return undefined
      }
    })()

  let stats = { signups: 0, paid: 0, incidents: 0, failedJobs: 0 }
  if (url && serviceKey) {
    const supabase = createClient(url, serviceKey)
    const { count: signups } = await supabase
      .from('profiles')
      .select('*', { count: 'exact', head: true })
      .gte('created_at', `${day}T00:00:00.000Z`)
      .lt('created_at', `${day}T23:59:59.999Z`)
    stats = {
      signups: signups ?? 0,
      paid: 3,
      incidents: 1,
      failedJobs: 0,
    }
  } else {
    stats = { signups: 12, paid: 3, incidents: 1, failedJobs: 0 }
  }

  const subject = `Acme daily digest — ${day}`
  const text = [
    `Signups: ${stats.signups}`,
    `Paid: ${stats.paid}`,
    `Open incidents: ${stats.incidents}`,
    `Failed jobs: ${stats.failedJobs}`,
  ].join('\n')
  const html = `
    <h1>Daily digest — ${day}</h1>
    <ul>
      <li>Signups: <b>${stats.signups}</b></li>
      <li>Paid: <b>${stats.paid}</b></li>
      <li>Open incidents: <b>${stats.incidents}</b></li>
      <li>Failed jobs: <b>${stats.failedJobs}</b></li>
    </ul>
    <p><a href="https://app.example.com/admin">Open admin</a></p>
  `
  return { subject, html, text, stats }
}

async function sendTransactional(opts: {
  to: string | string[]
  subject: string
  html: string
  text: string
  idempotencyKey: string
  tags: Record<string, string>
}) {
  const res = await fetch(ROVELA_URL, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${requireEnv('ROVELA_API_KEY')}`,
      'Content-Type': 'application/json',
      'Idempotency-Key': opts.idempotencyKey,
    },
    body: JSON.stringify({
      from: requireEnv('MAIL_FROM'),
      to: opts.to,
      subject: opts.subject,
      html: opts.html,
      text: opts.text,
      tags: opts.tags,
    }),
  })
  const body = await res.json().catch(() => ({}))
  if (!res.ok) {
    throw new Error(`Rovela ${res.status}: ${(body as { message?: string }).message ?? 'send failed'}`)
  }
  return body as { object: string; id: string }
}

Deno.serve(async (_req) => {
  try {
    const kind = Deno.env.get('DIGEST_KIND') || 'daily'
    const day = utcDayKey()
    const digest = await buildDailyDigest(day)
    const to = requireEnv('DIGEST_TO')
      .split(',')
      .map((s) => s.trim())
      .filter(Boolean)
    const idempotencyKey = windowKey(kind)

    console.log(JSON.stringify({ kind, day, idempotencyKey /* 永不记录 ROVELA_API_KEY */ }))

    const result = await sendTransactional({
      to,
      subject: digest.subject,
      html: digest.html,
      text: digest.text,
      idempotencyKey,
      tags: {
        kind: `supabase-cron-${kind}-digest`,
        day,
        platform: 'supabase-pg-cron',
      },
    })

    return Response.json({ ok: true, emailId: result.id, idempotencyKey })
  } catch (err) {
    console.error(err)
    return Response.json(
      { ok: false, error: err instanceof Error ? err.message : 'failed' },
      { status: 500 },
    )
  }
})

部署:

supabase functions deploy daily-digest --no-verify-jwt

当 Cron 用 Vault 里的 secret 作为 apikey(而不是终端用户 JWT)鉴权时,使用 --no-verify-jwt(或 Dashboard 等价项)(schedule functions)。若函数 URL 对外可达,仍应在函数内校验该 secret。

Rovela 的 Idempotency-Key(最长 256 字符)让同一发送在 24 小时内重试时返回原始响应,而不会再发一封(Send Email API)。这对 Supabase Cron 很重要,因为:

  • 调试函数时 pg_cron / Dashboard 可能再次触发
  • 亚分钟或重叠调度可能重新进入同一日历窗口
  • 手动 curl / Dashboard「Run」可能与定时触发重叠
  • Edge Function 在瞬时网络抖动后重试,否则会双发

推荐的键:

Key适用场景
supabase-cron-daily-digest:{yyyy-mm-dd}每个 UTC 日一封运维摘要
supabase-cron-hourly-ops:{yyyy-mm-dd}T{HH}每小时快照(0 * * * *)
supabase-cron-fifteen-ops:{yyyy-mm-dd}T{HH}:{mm}每 15 分钟脉冲(mm 取 00/15/30/45)
supabase-cron-weekly-report:{yyyy}-W{ww}每个 ISO 周一封创始人报告
supabase-cron-customer-digest:{userId}:{yyyy-mm-dd}按用户摘要(每个收件人窗口一把键)

若要发大量个性化摘要,按收件人循环并使用不同的键(或对最多 100 封非模板消息用 POST /emails/batch——batch 会按设计拒绝 template / topic_id(Send Email API))。控制并发;遇到 429 退避并用同一把键重试。保持墙钟时间短,让 Cron Job 干净结束。

用 pg_cron + pg_net 调度 Edge Function

每天 13:00 UTC 示例(按项目时区确认 pg_cron 的解释方式,通常为数据库时区 / UTC):

select
  cron.schedule(
    'invoke-daily-digest',
    '0 13 * * *', -- 每天 13:00
    $$
    select
      net.http_post(
        url := (select decrypted_secret from vault.decrypted_secrets where name = 'project_url')
               || '/functions/v1/daily-digest',
        headers := jsonb_build_object(
          'Content-Type', 'application/json',
          'apikey', (select decrypted_secret from vault.decrypted_secrets where name = 'secret_key')
        ),
        body := jsonb_build_object('time', now(), 'kind', 'daily'),
        timeout_milliseconds := 10000
      ) as request_id;
    $$
  );

每分钟冒烟测试(验证后删掉):

select
  cron.schedule(
    'invoke-daily-digest-smoke',
    '* * * * *',
    $$
    select
      net.http_post(
        url := (select decrypted_secret from vault.decrypted_secrets where name = 'project_url')
               || '/functions/v1/daily-digest',
        headers := jsonb_build_object(
          'Content-Type', 'application/json',
          'apikey', (select decrypted_secret from vault.decrypted_secrets where name = 'secret_key')
        ),
        body := jsonb_build_object('time', now()),
        timeout_milliseconds := 5000
      ) as request_id;
    $$
  );

也可在 Dashboard:Integrations → Cron → Create job,选调度,目标选 Supabase Edge Function,挑选 daily-digest(quickstart)。

测试结束后取消调度:

select cron.unschedule('invoke-daily-digest-smoke');

查看最近运行:

select *
from cron.job_run_details
where jobid = (select jobid from cron.job where jobname = 'invoke-daily-digest')
order by start_time desc
limit 20;

可选:从 SQL 直接调 Rovela?生产请走 Edge Function

你可以在 Cron SQL 里用 Vault 中的 Bearer,经 net.http_post 直达 https://api.rovela.dev/emails。生产发信请避免。 解密后的 Bearer 会在请求等待期间出现在 net.http_request_queue(schedule functions 警示),并且你会失去 TypeScript 聚合、结构化日志,以及用 supabase secrets set 更轻松轮换密钥的能力。默认保持 Cron → Edge Function → Rovela。

不等 Cron,先验证单次发送

curl -i -X POST \
  "https://YOUR_PROJECT_REF.supabase.co/functions/v1/daily-digest" \
  -H "Content-Type: application/json" \
  -H "apikey: YOUR_SUPABASE_SECRET_KEY" \
  -d '{"manual":true}'

期望 { "ok": true, "emailId": "…", "idempotencyKey": "supabase-cron-daily-digest:…" }。同一天用同一把键再跑一次——Rovela 应在 24 小时内返回同一个 email id,而不是再投一封(Send Email API)。

排障

现象可能原因处理
Cron 成功但没有邮件函数 500 / Rovela 4xx;Cron 只看到 pg_net 接受查 Edge Function 日志;用 Rovela 控制台 / full_access 查消息事件
Rovela 401key 错误或已吊销,或 header 不对Authorization: Bearer re_…;重建 sending_access
Rovela 400from 域名未验证、缺正文、收件人 >50验证域名;发送 html 和/或 text;拆分收件人
Rovela 429限流退避;用同一把 Idempotency-Key 重试
函数从未被调用Job 未激活、名称错误、未启用 pg_net查 cron.job / Dashboard Active;启用 pg_net
摘要重复键太粗或缺失使用按窗口的键;Cron 驱动的发送不要省略 Idempotency-Key
库里能看到 secret把 Rovela key 放进 Vault + pg_net headers把 Rovela key 只放在 Edge Function secrets

生产检查清单

  • Rovela 域名已验证;from 使用该域名
  • Edge Function 使用 sending_access(re_…);Cron 路径绝不用 full_access
  • ROVELA_API_KEY 只在 Edge Function secrets——不在 Cron SQL、Git 或客户端
  • Vault 保存 project_url + 调用用的 secret_key
  • 调度函数按服务间方式鉴权(关闭 verify_jwt + 校验 secret,或文档等价方案)
  • 每次 Cron 驱动的发送都设置按窗口的 Idempotency-Key
  • Job 远低于约 10 分钟指导值;为 cron.job_run_details 建清理 Job
  • 与 Auth OTP / 魔法链接流程区分开(Supabase 事务邮件 API)

小结

Supabase Cron(pg_cron)加上 pg_net,让你在 Postgres 里用五段(或亚分钟)节奏唤醒 Edge Function。调用凭证放 Vault,Rovela sending_access 放 Edge Function secrets,在 Deno 里聚合实时状态,再带按窗口的 Idempotency-Key 调用 POST https://api.rovela.dev/emails。Rovela 负责入队、签名与投递——时钟由 Supabase 掌管。

下一步:用 Supabase + Rovela 事务邮件 单独接 Auth 生命周期邮件,或用 定时发送邮件 把已知正文排到稍后投递。