Netlify Scheduled Functions 事务邮件 + Rovela(2026):Cron 配置与按窗口的幂等键
在 Netlify 上用 UTC cron(内联 config 或 netlify.toml)跑 Scheduled Function,从 Functions 作用域密钥读取 ROVELA_API_KEY,并用对齐调度窗口的 Idempotency-Key 调用 POST /emails。与 Fly Machines、Render Cron、Railway cron、Vercel HTTP cron、GitHub Actions 区分。
Netlify Scheduled Functions 负责 cron — Rovela 负责投递
Netlify Scheduled Functions 是带 UTC cron 表达式 的无服务器函数(内联 config.schedule 或写在 netlify.toml)。它很适合摘要、运维报告,以及其他正文依赖 触发时刻实时状态 的事务邮件——注册数、失败任务、未付发票——而不是几小时前就能用 Rovela scheduled_at 冻住的内容。
当你的定时函数调用 POST https://api.rovela.dev/emails 时,Rovela 负责入队、DKIM 签名与投递(Send Email API、Quick Start、Authentication)。Netlify 负责调度、已发布部署上的运行,以及你的 环境变量 / Secrets Controller。你的函数负责聚合,以及按调度窗口稳定的 Idempotency-Key。Rovela 只负责投递。
本指南会接好一个 Netlify Scheduled Function,使它:
- 从 Netlify 读取
ROVELA_API_KEY、MAIL_FROM与收件人环境变量(Functions 作用域;绝不写死,也不放进netlify.toml) - 按当前 UTC 窗口聚合摘要 / 报告
- 用对齐该窗口的
Idempotency-Key经 Rovela 发送 - 在 Scheduled Function 的 30 秒 时限内完成
Rovela 不会创建 Netlify 调度、不会设置 Netlify 环境变量、也不会替你保活函数。 Netlify 管 cron → 调用;你的 handler 管查询 → 映射字段 → POST /emails;Rovela 管投递。
与下列文章区分:
- Fly Scheduled Machines 邮件 — 粗粒度的
hourly/daily/weekly/monthlyMachine 间隔,不是五段 crontab - Render Cron 事务邮件 — 必须退出的 Cron Job 进程;有 run 在跑时会推迟下一次调度
- Railway Cron 事务邮件 — Railway cron 服务;重叠触发会被 跳过
- Vercel Cron 摘要邮件 — 对生产路由的 HTTP
GET+CRON_SECRET,不是 Netlify Scheduled Function - GitHub Actions 事务邮件 — 工作流 /
run_id的 CI 通知,不是平台函数 cron - 定时发送邮件 — 正文已知时用 Rovela
scheduled_at
Netlify 调度做什么,Rovela 发什么
| 关注点 | 负责方 |
|---|---|
UTC cron 表达式(0 13 * * * 或 @daily) | Netlify(Scheduled Functions) |
内联 export const config = { schedule }(JS/TS)或 netlify.toml | 你的仓库 |
| 环境变量 / secret 标记(UI、CLI、API;Functions 作用域) | Netlify + 站点配置 |
| 查询该窗口的 DB / API | 你的定时函数 |
| 渲染 HTML + text | 你的定时函数 |
POST /emails、DKIM、队列、退信 | Rovela |
Idempotency-Key = netlify-{kind}-{yyyy-mm-dd}(或小时 / 周) | 你的 handler |
| 30 秒执行上限;生产环境不可用 URL 直接调用 | Netlify |
Rovela 返回 200 表示 已接受并入队,不是「已进收件箱」(Quick Start)。
会影响发信任务的 Netlify 约束
按 Netlify 文档,发信调度应把这些当作硬要求:
- UTC cron。 表达式按 UTC 执行。本地「周一上午 9 点」要自己换算进表达式(Scheduled Functions)。
- 两种配置位置。 JS/TS 可导出
config.schedule;任意语言都可在netlify.toml的[functions."<name>"]下写 schedule。内联 schedule 仅 适用于 TypeScript 与 JavaScript。 - 请求体里的
next_run。 定时调用的 body 是含next_run的 JSON——下一次调度的 ISO-8601 时间戳。可写日志;不要 用「进程启动的那一秒」当幂等身份。 - 30 秒上限。 Scheduled functions 有 30 秒 执行限制。更长的工作应放到 background function 或外部 worker——不要指望定时函数既跑重活又完成 Rovela 往返。
- 仅已发布部署自动跑。 自动调度只对 已发布 的生产部署生效。Deploy Preview / branch deploy 不会自动按表触发;仍可用 Functions UI 的 Run now 测试。
- 生产环境不能用 URL 调用。 生产中不能靠 URL 打到 scheduled function(Functions API 里
schedule与path互斥)。调试用 Run now,或本地在 Netlify Dev 下用netlify functions:invoke(Dev 不会 按 cron 钟走)。 - 支持的别名。
@yearly、@monthly、@weekly、@daily、@hourly可用;不支持@reboot与@annually。 - 无响应流。 Scheduled functions 不对调用方返回有用的响应体——按副作用(你的 Rovela
POST)与日志来设计。
短任务(每日摘要、能在 30 秒内完成的每小时运维快照)用 Scheduled Functions;扇出或重查询请拆出定时路径。
何时用 Netlify 调度 + POST /emails(而不是 scheduled_at)
适合 Netlify Scheduled Function → 现场发送 的情况:
- HTML 必须包含触发时刻的 新鲜 指标
- 你已在 Netlify 上托管站点 / 函数,希望调度贴近该部署
- 一封(或少量)事务邮件发往运行时查到的固定运维 / 创始人 / 客户地址
- 整段工作(含 Rovela HTTP)能舒服地落在 30 秒 内
正文已经正确、只需「稍后再投」且不想跑任务时,用 Rovela scheduled_at(定时发送邮件)。Cron 与 scheduled_at 解决不同问题;本文是 Netlify Scheduled Functions 路径。
前置条件
- Rovela 里已 验证发信域名。未验证的
from域名会被400拒绝(Quick Start)。 - 形如
re_…的 API key。函数优先用sending_access(Authentication)。 - 带 Functions 目录、且已有已发布生产部署的 Netlify 站点。
- 该站点上 Functions 作用域的环境变量:
ROVELA_API_KEY、MAIL_FROM、DIGEST_TO(或在代码里查收件人),以及 DB / API 凭证。API key 建议标成 secret(Secrets Controller)。 - UTC cron 表达式——JS/TS 可内联,任意语言可写在
netlify.toml。
在 Netlify 上配置环境密钥
Scheduled functions 读取的运行时环境与其他 Netlify Functions 相同。用 UI(Project configuration → Environment variables)、CLI 或 API 配置——不要只写在 netlify.toml。仅在 netlify.toml 声明的变量在 Functions 运行时 不可用(Functions environment variables、overview)。
把 Rovela key 的作用域限到 Functions(只有构建步骤真需要时才加 Builds)。生产专用值请显式设 Production deploy context。
# CLI 示例——值为示意;切勿提交真实密钥
netlify env:set ROVELA_API_KEY 're_your_sending_access_key' --secret
netlify env:set MAIL_FROM 'Acme Reports <reports@mail.example.com>'
netlify env:set DIGEST_TO 'founders@example.com'
加上 --secret / Contains secret values 后,该值在 Netlify 托管代码之外为只写,并限制可能泄露的作用域(例如 post-processing / snippet injection)(Secrets Controller)。
函数侧环境变量变更需要 重新部署 才生效。不要 console.log API key。轮换:新建 Rovela key → 更新 Netlify 变量 → 重新部署 → 作废旧 key(Authentication)。
创建 Scheduled Function
方案 A — 内联 schedule(JavaScript / TypeScript)
如需类型辅助:
npm install @netlify/functions
// netlify/functions/daily-digest.ts
import type { Config } from "@netlify/functions"
const ROVELA_URL = "https://api.rovela.dev/emails"
function requireEnv(name: string): string {
const v = process.env[name]
if (!v) throw new Error(`missing env ${name}`)
return v
}
function utcDayKey(d = new Date()) {
return d.toISOString().slice(0, 10) // YYYY-MM-DD
}
async function buildDailyDigest(day: string) {
// 换成你对 Neon / PlanetScale / 自有 API 的查询
const 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(() => ({} as any))
if (!res.ok) {
throw new Error(`Rovela ${res.status}: ${body.message ?? "send failed"}`)
}
return body as { object: "email"; id: string }
}
export default async (req: Request) => {
const { next_run } = await req.json()
const day = utcDayKey()
const digest = await buildDailyDigest(day)
const to = requireEnv("DIGEST_TO")
.split(",")
.map((s) => s.trim())
.filter(Boolean)
// 每个产品每个 UTC 日一次逻辑发送——Run now / 重试也安全
const result = await sendTransactional({
to,
subject: digest.subject,
html: digest.html,
text: digest.text,
idempotencyKey: `netlify-daily-digest:${day}`,
tags: {
kind: "netlify-daily-digest",
day,
platform: "netlify",
next_run: String(next_run ?? ""),
},
})
console.log(
JSON.stringify({
ok: true,
day,
emailId: result.id,
next_run,
stats: digest.stats,
}),
)
}
export const config: Config = {
schedule: "0 13 * * *", // 每天 13:00 UTC
}
方案 B — 写在 netlify.toml
保留同一 handler,但 不要 export const config,把 cron 写进配置(非 JS 语言必须如此;JS 也可以):
[functions."daily-digest"]
schedule = "0 13 * * *"
调度示例(均为 UTC):
| 表达式 | 含义(UTC) |
|---|---|
0 13 * * * | 每天 13:00 |
@daily | 每天 00:00 |
0 * * * * / @hourly | 每小时整点 |
0 0 * * 1 | 每周一 00:00 |
@weekly | 每周日 00:00 |
部署后,函数会在 Functions 页显示 Scheduled 徽章与下次运行时间(UI 会换算到你的时区)。
面向 Run now 与重试的幂等
Rovela 的 Idempotency-Key(最长 256 字符)让 同一 发送在 24 小时 内重试时返回原响应,而不是再寄一封(Send Email API)。在 Netlify 上这很重要,因为:
- 调试时可按 Run now
- 网络抖动可能重试
fetch - 你可能重新部署后,对手动再触发同一日历窗口
把身份键到 调度窗口,不要用 Date.now() 或随机 UUID:
| Key | 适用场景 |
|---|---|
netlify-daily-digest:{yyyy-mm-dd} | 每个 UTC 日一封运维摘要 |
netlify-hourly-ops:{yyyy-mm-dd}T{HH} | 每小时快照(对齐 @hourly / 0 * * * *) |
netlify-weekly-report:{yyyy}-W{ww} | 每个 ISO 周一封创始人报告 |
netlify-customer-digest:{userId}:{yyyy-mm-dd} | 按用户摘要(每个收件人窗口一把 key) |
若要发 多封 个性化摘要,按收件人循环并各自用新 key(或对最多 100 条非模板消息用 POST /emails/batch——batch 会按设计拒绝 template / topic_id(Send Email API))。控制并发;遇到 429 退避并用 同一 key 重试。总耗时保持在 30 秒内。
把工作压进 30 秒
实用建议,避免定时函数在 Rovela 接受消息前超时:
- 预先把指标聚合成函数一次查询就能读的表 / 视图
- 优先一次
POST /emails(或一小批),不要几十次串行调用 - 给
fetch设紧超时,宁可失败也不要挂死 - 重导出 / PDF 生成放到 background function 或 worker;调度只负责「发送已建好的摘要」
本地调试与 Deploy Preview
- 本地:
netlify dev+netlify functions:invoke daily-digest会跑一次 handler。Netlify Dev 不会 按 cron 钟触发(Scheduled Functions)。 - UI: Functions → 选中定时函数 → Run now(Deploy Preview / branch deploy 上也可用于测试)。
- 生产调度: 只有已发布的生产部署会自动按表运行。
若在 preview 上用 Run now,把 preview 环境指到非生产 Rovela key / 邮箱——Production context 的密钥应保持仅生产可用。
故障排查
| 现象 | 检查项 |
|---|---|
Rovela 400 点名某个域名 | MAIL_FROM 域名未在 Rovela 验证 |
Rovela 401 | ROVELA_API_KEY 缺失 / 错误、作用域不对,或环境变量未重新部署 |
Rovela 429 | 退避;用 同一 Idempotency-Key 重试 |
| 函数超时(约 30s) | 缩小查询;减少发送次数;把长任务移出调度 |
| 调度从不触发 | 确认是 已发布 生产部署;在 Functions 页看 Scheduled 徽章 |
| Run now 后出现重复邮件 | key 必须按窗口稳定(netlify-daily-digest:{day}),不能每次 invoke uuid() |
非 2xx 时按原样记录 Rovela 的 message 字段(Quick Start)。生产投递状态优先用 webhook,而不是轮询 GET /emails/:id(Send Email API)。读事件需要 full_access;定时函数只保留 sending_access。
清单
- 已验证 Rovela 发信域名;
sending_accesskey 在 Netlify 环境变量中(Functions 作用域、已标 secret),不在 Git /netlify.toml - UTC
schedule内联或写在netlify.toml;发布后 Functions 页有 Scheduled 徽章 - 触发时刻做实时聚合;尽量同时设 HTML + text
Idempotency-Key对齐调度窗口(netlify-…:{day})- 含
POST /emails在内,任务远低于 30 秒完成 - 用 Run now /
functions:invoke调试;用 Rovela 控制台或 webhook 看投递