← 返回博客

Vercel Cron 摘要邮件 + Rovela(2026):CRON_SECRET、POST /emails 与幂等键

用 CRON_SECRET 保护 Vercel Cron App Router 路由,从数据库聚合新鲜的每日/每周摘要或运维报告,并以按 UTC 运行窗口作用域的 Idempotency-Key 通过 Rovela POST /emails 发送。密钥放在项目环境变量。与 Cloudflare Workers 事务邮件、AI agent HTTP 发信、以及 Rovela scheduled_at 区分。

更新于 2026年11月4日
Vercel Cron 摘要邮件 + Rovela(2026):CRON_SECRET、POST /emails 与幂等键

Vercel Cron 负责汇总 — Rovela 负责投递

Vercel Cron Jobs 会按计划对你的 生产 部署发起请求。你的 App Router(或其他框架)路由查询数据库、渲染每日/每周摘要或运维报告,再通过 Rovela HTTP API 发出。当正文依赖发送当下的状态(计数、失败任务、新注册、未付发票)时,这比用 scheduled_at 提前几小时冻结内容更合适。

你的 cron 路由调用 POST https://api.rovela.dev/emails 时,Rovela 负责入队、DKIM 签名与投递(Send Email API、Quick Start、Authentication)。Vercel 拥有调度与 GET 调用;你的应用拥有鉴权(CRON_SECRET)、聚合,以及按运行窗口稳定的 Idempotency-Key。Rovela 只负责投递。

本指南会接好一条受保护的 cron 路由,做到:

  1. 拒绝未携带 Authorization: Bearer ${CRON_SECRET} 的调用(Managing Cron Jobs)
  2. 按当前 UTC 窗口从自有数据聚合 digest / 报告
  3. 通过 Rovela 发送一封(或少量)事务邮件,并用窗口维度的 Idempotency-Key 去重
  4. 把 ROVELA_API_KEY、CRON_SECRET、MAIL_FROM 放在 Vercel 项目环境变量里——绝不进客户端包

Rovela 不会创建 Vercel Cron、不会设置 CRON_SECRET、也不会读你的数据库。 Vercel 拥有调度与生产环境 GET;你的路由拥有校验 → 聚合 → 字段映射;Rovela 拥有 POST /emails。

与这些文章区分开:

Vercel Cron 做什么,Rovela 发什么

关注点负责方
Cron 表达式 + 仅生产环境对路径发 GETVercel(Cron Jobs)
CRON_SECRET → 每次调用带 Authorization: Bearer …Vercel + 你的路由校验
查询 Postgres / KV / Stripe 取窗口数据你的应用
渲染 HTML + text 摘要/报告你的应用
POST /emails、DKIM、队列、退信Rovela
Idempotency-Key = digest-{kind}-{yyyy-mm-dd}(或周 id)你的 cron 路由

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

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

适合 Vercel Cron → 即时发送 的情况:

  • HTML 必须包含新鲜指标(今日注册、未关闭事故、MRR 变化)
  • 否则你得自建每分钟扫「到期 digest」表的 worker
  • 收件人是固定的 ops / founder / 客户地址,或在运行时查询

适合 Rovela scheduled_at 的情况:正文已经正确,只需要「在用户时区早上 9 点投递」(定时发送)。Cron 与 scheduled_at 解决不同问题;本文走 Cron 路径。

前置条件

  1. 在 Rovela 控制台完成发信域名验证。未验证的 from 域名会直接 400 拒绝(Quick Start)。可用 reports.example.com 或 mail.example.com。
  2. API key 形如 re_…。生产 cron 建议只用 sending_access(Authentication)。
  3. 已有 Vercel 项目且存在生产部署。Cron 只在生产部署上运行,不会在 preview 上跑(Getting started with cron jobs)。
  4. 项目环境变量(Production):CRON_SECRET、ROVELA_API_KEY、MAIL_FROM,以及 digest 所需的数据库 / API 凭证。
  5. 添加环境变量后重新部署,生产函数才能读到新值。

环境变量

在 Vercel 控制台(Project → Settings → Environment Variables)或 CLI:

# 生成足够长的随机密钥(Vercel 建议至少 16 字符)
openssl rand -hex 32
# 设为 Production 的 CRON_SECRET,然后重新部署

# Rovela
ROVELA_API_KEY=re_your_sending_access_key
MAIL_FROM='Acme Reports <reports@mail.example.com>'
DIGEST_TO='founders@example.com'   # 也可在代码里查询收件人

不要加 NEXT_PUBLIC_ 前缀。不要把 key 提交进仓库。轮换 Rovela key:新建 → 部署 → 吊销旧 key(Authentication)。轮换 CRON_SECRET 需在包含新值的部署生效后一起更新。

在 vercel.json 声明调度

Vercel 用 HTTP GET 调用你声明的 path。时间表是 UTC 的 cron 表达式(Cron Jobs)。

{
  "$schema": "https://openapi.vercel.sh/vercel.json",
  "crons": [
    {
      "path": "/api/cron/daily-digest",
      "schedule": "0 13 * * *"
    }
  ]
}

0 13 * * * 表示每天 13:00 UTC。选择「昨日」或「今日目前为止」指标已经稳定的窗口。Hobby 计划的频率限制严于 Pro——按 Vercel Cron 文档 核对你的套餐,不要默认能跑每小时任务。

Cron 调用会带 User-Agent: vercel-cron/1.0,以及包含触发表达式的 x-vercel-cron-schedule。它们不会跟随重定向——若开启了 trailingSlash,cron 的 path 必须完全匹配(Troubleshooting)。

用 CRON_SECRET 保护路由

任何人猜到 /api/cron/daily-digest 都能打到公开 URL。项目设置了 CRON_SECRET 后,Vercel 会在每次 cron 请求带上 Authorization: Bearer <value>。在查库或发信之前先比对(Managing Cron Jobs)。

// app/api/cron/daily-digest/route.ts
import { NextRequest } from 'next/server'

export const dynamic = 'force-dynamic'
// 可选:聚合耗时长时提高函数时长上限
// export const maxDuration = 60

function unauthorized() {
  return new Response('Unauthorized', { status: 401 })
}

export async function GET(request: NextRequest) {
  const secret = process.env.CRON_SECRET
  const auth = request.headers.get('authorization')
  if (!secret || auth !== `Bearer ${secret}`) {
    return unauthorized()
  }

  // … 聚合 + 发送(见下)
  return Response.json({ ok: true })
}

!secret 守卫很重要:否则环境变量缺失时可能接受 Bearer undefined。若 middleware 会把未登录浏览器重定向到 /login,请把 /api/cron 排除在外——cron 不会跟随该重定向。

先聚合,再 POST /emails

聚合逻辑放在你自己的模块里。下面是最小发送封装,以及按窗口固定的幂等键。Rovela 的 Idempotency-Key(最多 256 字符)让 24 小时内相同键的重试返回原响应,而不会再发一封(Send Email API)。

// lib/rovela.ts
import 'server-only'

type SendInput = {
  to: string | string[]
  subject: string
  html: string
  text: string
  idempotencyKey: string
  tags?: Record<string, string>
}

export async function sendTransactional(input: SendInput) {
  const res = await fetch('https://api.rovela.dev/emails', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.ROVELA_API_KEY}`,
      'Content-Type': 'application/json',
      'Idempotency-Key': input.idempotencyKey,
    },
    body: JSON.stringify({
      from: process.env.MAIL_FROM,
      to: input.to,
      subject: input.subject,
      html: input.html,
      text: input.text,
      tags: input.tags,
    }),
    cache: 'no-store',
  })

  const body = await res.json()
  if (!res.ok) {
    throw new Error(`Rovela ${res.status}: ${body.message ?? 'send failed'}`)
  }
  return body as { object: 'email'; id: string }
}

/** UTC 日历日作为运行窗口 — 同一天重试保持稳定 */
export function utcDayKey(d = new Date()) {
  return d.toISOString().slice(0, 10) // YYYY-MM-DD
}

接到 cron handler:

// app/api/cron/daily-digest/route.ts(续)
import { sendTransactional, utcDayKey } from '@/lib/rovela'
import { buildDailyDigest } from '@/lib/digest' // 你的查询 + HTML

export async function GET(request: NextRequest) {
  const secret = process.env.CRON_SECRET
  const auth = request.headers.get('authorization')
  if (!secret || auth !== `Bearer ${secret}`) {
    return new Response('Unauthorized', { status: 401 })
  }

  const day = utcDayKey()
  const digest = await buildDailyDigest(day) // { subject, html, text, stats }

  const result = await sendTransactional({
    to: process.env.DIGEST_TO!.split(',').map((s) => s.trim()),
    subject: digest.subject,
    html: digest.html,
    text: digest.text,
    // 每个产品每个 UTC 日一封逻辑邮件 — Vercel 或你重试也安全
    idempotencyKey: `daily-digest:${day}`,
    tags: { kind: 'daily-digest', day },
  })

  return Response.json({ ok: true, day, emailId: result.id, stats: digest.stats })
}

推荐键:

键场景
daily-digest:{yyyy-mm-dd}每个 UTC 日一封运维摘要
weekly-report:{yyyy}-W{ww}每个 ISO 周一封创始人报告
customer-digest:{userId}:{yyyy-mm-dd}按用户的摘要(每个收件人窗口一把键)

若要发大量个性化 digest,为每个收件人使用独立键循环(或对最多 100 条非模板消息用 POST /emails/batch——batch 会故意拒绝 template / topic_id(Send Email API))。控制并发以免触达速率限制;遇到 429 退避并用同一把键重试。

摘要正文示例(保持事务性)

发给团队、或发给单个客户关于其账户的摘要属于事务邮件。不要把营销群发塞进 cron——营销活动应走营销工具与合规名单。

// lib/digest.ts(示意)
export async function buildDailyDigest(day: string) {
  const stats = await loadStatsForDay(day) // 你的 DB
  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 }
}

同时发送 html 与 text。把数字放进主题或 text 前几行,方便通知栏预览。

可选:用控制台模板代替内联 HTML

版式稳定时,可在 Rovela 控制台存模板并传变量(与单次 POST /emails 上的内联 html / text 互斥):

body: JSON.stringify({
  from: process.env.MAIL_FROM,
  to: process.env.DIGEST_TO,
  template: {
    id: process.env.ROVELA_DIGEST_TEMPLATE_ID,
    variables: {
      day,
      signups: String(stats.signups),
      paid: String(stats.paid),
      incidents: String(stats.incidents),
    },
  },
})

Batch 不能使用 template——模板消息请走单次 POST /emails(Send Email API)。

本地测试(不必等调度)

Cron 只在生产触发。本地:

curl -i "http://localhost:3000/api/cron/daily-digest" \
  -H "Authorization: Bearer $CRON_SECRET"

无 header 应 401,有 header 应 200,JSON 里有一个 Rovela message id。用同一 header 手动打生产 URL 也可补发——按日的 Idempotency-Key 会在 24 小时内防止与已成功的定时运行重复发信。

值得处理的失败模式

现象常见原因
Cron「成功」但没邮件在 POST /emails 之前就返回了 200,或发送抛错前已响应——先 await 发送再响应
Vercel 日志里路由 401Production 缺 CRON_SECRET、写错,或 middleware 重定向
Rovela 400 点名域名from 不在已验证域名上(Quick Start)
Rovela 401ROVELA_API_KEY 错误/已吊销,或密钥类型不匹配
Rovela 429触达速率限制——退避并用同一 Idempotency-Key 重试
收到重复摘要键太粗(缺日期)或每次尝试随机生成
Job 从不运行Preview 部署、cron path 错误,或尾斜杠重定向

生产可观测性请订阅 Rovela webhook(email.delivered、email.bounced 等),不要在 cron 里轮询 GET /emails/:id(读取需要 full_access 或 session;sending_access 只能发信(Authentication))。

部署清单

  1. 在 Rovela 验证发信域名;按控制台完成 SPF / DKIM / DMARC / return-path。
  2. 创建 sending_access key;在 Production 设置 ROVELA_API_KEY、MAIL_FROM、DIGEST_TO、CRON_SECRET。
  3. 提交带 cron path + UTC 调度的 vercel.json;部署到生产。
  4. 生产构建后在 Vercel 项目 Cron UI 确认任务出现。
  5. 用 Bearer secret 手动触发一次;确认一个 message id 与一封收件。
  6. 等调度跑一次;日志应只有一个 Rovela id(若你再次打 URL,应幂等回放)。

小结

Vercel Cron 就是定时的生产环境 GET。你的路由校验 CRON_SECRET,构建新鲜的摘要或报告,并用按运行窗口作用域的 Idempotency-Key 调用 Rovela POST /emails。密钥留在项目环境变量里。这就是海外 SaaS 做每日运维摘要与客户活动报告的常见做法——不必另起邮件 worker,也不必在数字必须实时时把过期 HTML 冻进 scheduled_at。

下一步:用同样方式接故障告警(独立 path + 如 alert-incident:{id} 的键),或用 batch + 每收件人一键扇出按租户的 digest。