← 返回博客

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 区分。

更新于 2026年11月5日
Netlify Scheduled Functions 事务邮件 + Rovela(2026):Cron 配置与按窗口的幂等键

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,使它:

  1. 从 Netlify 读取 ROVELA_API_KEY、MAIL_FROM 与收件人环境变量(Functions 作用域;绝不写死,也不放进 netlify.toml)
  2. 按当前 UTC 窗口聚合摘要 / 报告
  3. 用对齐该窗口的 Idempotency-Key 经 Rovela 发送
  4. 在 Scheduled Function 的 30 秒 时限内完成

Rovela 不会创建 Netlify 调度、不会设置 Netlify 环境变量、也不会替你保活函数。 Netlify 管 cron → 调用;你的 handler 管查询 → 映射字段 → POST /emails;Rovela 管投递。

与下列文章区分:

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 路径。

前置条件

  1. Rovela 里已 验证发信域名。未验证的 from 域名会被 400 拒绝(Quick Start)。
  2. 形如 re_… 的 API key。函数优先用 sending_access(Authentication)。
  3. 带 Functions 目录、且已有已发布生产部署的 Netlify 站点。
  4. 该站点上 Functions 作用域的环境变量:ROVELA_API_KEY、MAIL_FROM、DIGEST_TO(或在代码里查收件人),以及 DB / API 凭证。API key 建议标成 secret(Secrets Controller)。
  5. 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 接受消息前超时:

  1. 预先把指标聚合成函数一次查询就能读的表 / 视图
  2. 优先一次 POST /emails(或一小批),不要几十次串行调用
  3. 给 fetch 设紧超时,宁可失败也不要挂死
  4. 重导出 / 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 401ROVELA_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。

清单

  1. 已验证 Rovela 发信域名;sending_access key 在 Netlify 环境变量中(Functions 作用域、已标 secret),不在 Git / netlify.toml
  2. UTC schedule 内联或写在 netlify.toml;发布后 Functions 页有 Scheduled 徽章
  3. 触发时刻做实时聚合;尽量同时设 HTML + text
  4. Idempotency-Key 对齐调度窗口(netlify-…:{day})
  5. 含 POST /emails 在内,任务远低于 30 秒完成
  6. 用 Run now / functions:invoke 调试;用 Rovela 控制台或 webhook 看投递

相关阅读