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。
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 串起来,做到:
- 把 Edge Function 调用凭证放进 Supabase Vault(不要以明文写进公开 migration)
- 把
ROVELA_API_KEY(sending_access)、MAIL_FROM、收件人放进 Edge Function secrets——绝不进浏览器、绝不进 Git - 用
cron.schedule+net.http_post(或 Dashboard 的「Edge Function」Job 目标)按摘要节奏调度 - 按当前窗口从 Postgres 聚合摘要 / 报告
- 经 Rovela 发送,并使用按窗口作用域的
Idempotency-Key - 在 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。
与下列文章区分:
- Kubernetes CronJob 事务邮件 —
batch/v1CronJob → Job Pod 启动后退出 - DigitalOcean App Platform Jobs 事务邮件 — App Platform
kind: SCHEDULEDJob - Heroku Scheduler 事务邮件 — 粗粒度 Scheduler dyno
- Cloudflare Workers Cron 事务邮件 — Workers
triggers.crons+scheduled() - Azure Functions Timer 事务邮件 — Azure NCRONTAB + App Settings
- GCP Cloud Scheduler 事务邮件 — Cloud Scheduler HTTP/OIDC 或 Pub/Sub
- AWS EventBridge Scheduler 事务邮件 — EventBridge Scheduler → Lambda
- Netlify Scheduled Functions 事务邮件 — Netlify UTC cron
- Fly Scheduled Machines 事务邮件 — Fly Machine 调度
- Render Cron 事务邮件 — Render Cron Job 进程
- Railway Cron 事务邮件 — Railway cron 服务
- Vercel Cron 摘要邮件 — 对生产路由发 HTTP
GET - GitHub Actions 事务邮件 — workflow / CI 通知
- 定时发送邮件 — 正文已定时,用 Rovela
scheduled_at - Supabase 事务邮件 API — Auth / 自定义 SMTP / OTP — 不是 pg_cron 摘要
Supabase Cron 做什么,Rovela 发什么
| 关注点 | 归属 |
|---|---|
五段 cron(或受支持的亚分钟 N seconds)+ Job 名 | Supabase Cron / pg_cron(Cron、quickstart) |
net.http_post 到 Edge Function URL | pg_net(pg_net、schedule functions) |
| 项目 URL + 调用用的 secret | Supabase 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_netbody。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。
前置条件
- Rovela 上有已验证发信域名。未验证的
from会400(Quick Start)。 - 形如
re_…的 API key。Edge Function 优先用sending_access(Authentication)。 - 具备 Cron(
pg_cron)与pg_net的 Supabase 项目,以及创建 Edge Function / secrets 的权限。 - Edge Function secrets:
ROVELA_API_KEY、MAIL_FROM、DIGEST_TO(可选DIGEST_KIND)。 - Vault secrets:
project_url与供net.http_post使用的 invokesecret_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 401 | key 错误或已吊销,或 header 不对 | Authorization: Bearer re_…;重建 sending_access |
Rovela 400 | from 域名未验证、缺正文、收件人 >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 生命周期邮件,或用 定时发送邮件 把已知正文排到稍后投递。