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 区分。
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 路由,做到:
- 拒绝未携带
Authorization: Bearer ${CRON_SECRET}的调用(Managing Cron Jobs) - 按当前 UTC 窗口从自有数据聚合 digest / 报告
- 通过 Rovela 发送一封(或少量)事务邮件,并用窗口维度的
Idempotency-Key去重 - 把
ROVELA_API_KEY、CRON_SECRET、MAIL_FROM放在 Vercel 项目环境变量里——绝不进客户端包
Rovela 不会创建 Vercel Cron、不会设置 CRON_SECRET、也不会读你的数据库。 Vercel 拥有调度与生产环境 GET;你的路由拥有校验 → 聚合 → 字段映射;Rovela 拥有 POST /emails。
与这些文章区分开:
- Cloudflare Workers 发事务邮件 — Worker + Wrangler secrets,不是 Vercel Cron
- AI agent 用 HTTP 发邮件 — agent / Cursor 临时
curl,不是生产调度 - 定时发送 scheduled_at — 正文已知、只需延后投递时用 Rovela
scheduled_at - Next.js 发邮件 — 用户触发的 Route Handler / Server Action,不是 cron 摘要
Vercel Cron 做什么,Rovela 发什么
| 关注点 | 负责方 |
|---|---|
Cron 表达式 + 仅生产环境对路径发 GET | Vercel(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 路径。
前置条件
- 在 Rovela 控制台完成发信域名验证。未验证的
from域名会直接400拒绝(Quick Start)。可用reports.example.com或mail.example.com。 - API key 形如
re_…。生产 cron 建议只用sending_access(Authentication)。 - 已有 Vercel 项目且存在生产部署。Cron 只在生产部署上运行,不会在 preview 上跑(Getting started with cron jobs)。
- 项目环境变量(Production):
CRON_SECRET、ROVELA_API_KEY、MAIL_FROM,以及 digest 所需的数据库 / API 凭证。 - 添加环境变量后重新部署,生产函数才能读到新值。
环境变量
在 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 日志里路由 401 | Production 缺 CRON_SECRET、写错,或 middleware 重定向 |
Rovela 400 点名域名 | from 不在已验证域名上(Quick Start) |
Rovela 401 | ROVELA_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))。
部署清单
- 在 Rovela 验证发信域名;按控制台完成 SPF / DKIM / DMARC / return-path。
- 创建
sending_accesskey;在 Production 设置ROVELA_API_KEY、MAIL_FROM、DIGEST_TO、CRON_SECRET。 - 提交带 cron path + UTC 调度的
vercel.json;部署到生产。 - 生产构建后在 Vercel 项目 Cron UI 确认任务出现。
- 用 Bearer secret 手动触发一次;确认一个 message id 与一封收件。
- 等调度跑一次;日志应只有一个 Rovela id(若你再次打 URL,应幂等回放)。
小结
Vercel Cron 就是定时的生产环境 GET。你的路由校验 CRON_SECRET,构建新鲜的摘要或报告,并用按运行窗口作用域的 Idempotency-Key 调用 Rovela POST /emails。密钥留在项目环境变量里。这就是海外 SaaS 做每日运维摘要与客户活动报告的常见做法——不必另起邮件 worker,也不必在数字必须实时时把过期 HTML 冻进 scheduled_at。
下一步:用同样方式接故障告警(独立 path + 如 alert-incident:{id} 的键),或用 batch + 每收件人一键扇出按租户的 digest。