Render Cron 事务邮件 + Rovela(2026):环境密钥与按窗口的幂等键
在 Render 上跑会干净退出的 Cron Job,从环境变量或 environment group 读取 ROVELA_API_KEY,并用对齐 UTC 调度窗口的 Idempotency-Key 调用 POST /emails。与 Railway cron、Vercel Cron 路由、GitHub Actions 区分。
Render Cron Job 负责调度 — Rovela 负责投递
Render Cron Jobs 是一种带 UTC cron 表达式、且 命令必须在结束后退出 的服务类型。它很适合摘要、运维报告,以及其他正文依赖 触发时刻实时状态 的事务邮件——注册数、失败任务、未付发票——而不是几小时前就能用 Rovela scheduled_at 冻住的内容。
当你的 cron 进程调用 POST https://api.rovela.dev/emails 时,Rovela 负责入队、DKIM 签名与投递(Send Email API、Quick Start、Authentication)。Render 负责调度、一次性运行,以及你的 环境变量 / environment group。你的代码负责聚合,以及按调度窗口稳定的 Idempotency-Key。Rovela 只负责投递。
本指南会接好一个 Render Cron Job,使它:
- 从 Render 读取
ROVELA_API_KEY、MAIL_FROM与收件人环境变量(绝不写死) - 按当前 UTC 窗口聚合摘要 / 报告
- 用对齐该窗口的
Idempotency-Key经 Rovela 发送 - 干净退出,以便停止计费,且不会无限推迟下一次调度
Rovela 不会创建 Render Cron Job、不会设置 Render 环境变量、也不会替你保活进程。 Render 管 start → exit;你的命令管查询 → 映射字段 → POST /emails;Rovela 管投递。
与下列文章区分:
- Railway Cron 事务邮件 — Railway cron 服务;重叠触发会被 跳过,而非推迟
- Vercel Cron 摘要邮件 — 对生产路由的 HTTP
GET+CRON_SECRET,不是 Render Cron Job 进程 - GitHub Actions 事务邮件 — 工作流 /
run_id的 CI 通知,不是平台 crontab - 定时发送邮件 — 正文已知时用 Rovela
scheduled_at
Render Cron 做什么,Rovela 发什么
| 关注点 | 负责方 |
|---|---|
| 五段 cron 表达式(UTC) | Render(Cron Jobs) |
会退出的 Command(或 Docker ENTRYPOINT/CMD) | Render + 你的仓库 |
| 环境变量 / environment group / secret files | Render + 服务配置 |
| 查询该窗口的 DB / API | 你的 cron 进程 |
| 渲染 HTML + text | 你的 cron 进程 |
POST /emails、DKIM、队列、退信 | Rovela |
Idempotency-Key = render-{kind}-{yyyy-mm-dd}(或小时 / 周) | 你的命令 |
| 同一时间最多一个 active run;单次最长 12 小时 | Render |
Rovela 返回 200 表示 已接受并入队,不是「已进收件箱」(Quick Start)。
会影响发信任务的 Render 约束
按 Render 文档,发信 cron 应把这些当作硬要求:
- 做完就退出。 Cron Jobs 按 active 运行时间计费;命令若留下 Web 服务或未关闭的连接池,会计费不停(Cron Jobs)。
- 调度按 UTC。 本地「周一上午 9 点」要自己换算进表达式。
- Single-run 保证。 同一 Cron Job 同一时间最多一个 active run。若调度时刻到来时仍有 run 在跑,Render 会 推迟 下一次调度直到当前跑完。手动 Trigger Run 会先取消当前 active run,再启动新的一次(Cron Jobs)。
- 12 小时硬停止。 超过 12 小时会被停掉;更长或持续任务应改用 background worker / workflow。
- Cron Job 不能挂 persistent disk。 摘要状态放数据库、对象存储或 API,不要依赖本地卷。
- 新的 Git 构建不会打断进行中的 run,只影响 之后 的 run;基于 Docker 镜像的 cron 每次 run 前拉取镜像,且不会在两次 run 之间保留镜像。
短任务(每日摘要、每小时运维快照)用 Render Cron;若不能被慢任务挡住,或需要持续处理,改用 worker / 队列。
何时用 Render Cron + POST /emails(而不是 scheduled_at)
适合 Render Cron → 现场发送 的情况:
- HTML 必须包含触发时刻的 新鲜 指标
- 你已在 Render 上托管 Web / DB / worker,希望调度贴近数据
- 一封(或少量)事务邮件发往运行时查到的固定运维 / 创始人 / 客户地址
正文已经正确、只需「稍后再投」且不想跑任务时,用 Rovela scheduled_at(定时发送邮件)。Cron 与 scheduled_at 解决不同问题;本文是 Render Cron 路径。
前置条件
- Rovela 里已 验证发信域名。未验证的
from域名会被400拒绝(Quick Start)。 - 形如
re_…的 API key。cron 服务优先用sending_access(Authentication)。 - Render 账号,以及一个 Cron Job 服务(Git 仓库或 Docker 镜像)。
- 该 Cron Job(或关联的 environment group)上的环境变量:
ROVELA_API_KEY、MAIL_FROM、DIGEST_TO(或在代码里查收件人),以及 DB / API 凭证。 - UTC 的 Schedule,以及会退出的 Command(原生)或会退出的 Docker 启动命令。
在 Render 上配置环境
在 Dashboard(Cron Job → Environment)或 Blueprint / environment group 中设置:
ROVELA_API_KEY=re_your_sending_access_key
MAIL_FROM='Acme Reports <reports@mail.example.com>'
DIGEST_TO='founders@example.com'
# 再加上 DATABASE_URL 等摘要查询所需变量
密钥不要进 Git。在 Blueprint(render.yaml)里对密钥使用 sync: false,在 Dashboard 填值(Environment Variables and Secrets)。Web 与 cron 共用同一 Rovela key 时,优先用 environment group。不要 console.log API key。轮换:新建 Rovela key → 更新 Render 变量 → 等下次 cron → 作废旧 key(Authentication)。
创建 Cron Job
Dashboard 大致步骤:
- New → Cron Job
- 连接 Git 仓库(或指定 Docker 镜像)
- 设置 Schedule(UTC),例如每天 13:00 UTC 用
0 13 * * * - 设置 Command 为一次性入口,例如
node src/digest-cron.mjs(纯镜像服务没有该字段——用镜像ENTRYPOINT/CMD或 Advanced Docker Command) - 添加环境变量 / 关联 environment group
- 部署;在 Builds 与 Runs 查看结果
Blueprint 示意(密钥走 Dashboard,不提交到仓库):
services:
- type: cron
name: nightly-digest-email
runtime: node
plan: starter
schedule: "0 13 * * *" # YAML 中必须给表达式加引号
buildCommand: npm ci
startCommand: node src/digest-cron.mjs
envVars:
- key: ROVELA_API_KEY
sync: false
- key: MAIL_FROM
sync: false
- key: DIGEST_TO
sync: false
上线前请对照 Render 文档确认 runtime、plan 与 Blueprint 字段——Rovela 不定义 Render 的 schema。
调度示例(均为 UTC):
| 表达式 | 含义(UTC) |
|---|---|
0 13 * * * | 每天 13:00 |
0 */6 * * * | 每 6 小时整点 |
*/10 * * * * | 每 10 分钟 |
*/60 * * * MON-FRI | 周一至周五每 60 分钟 |
不要把长期运行的 Web 服务当作 Cron Job 命令。进程必须结束。
最小 Node 入口
{
"name": "render-digest-cron",
"type": "module",
"scripts": {
"start": "node src/digest-cron.mjs"
},
"engines": { "node": ">=20" }
}
// src/digest-cron.mjs
const ROVELA_URL = 'https://api.rovela.dev/emails'
function requireEnv(name) {
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) {
// 换成你对 Render Postgres / 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({ to, subject, html, text, idempotencyKey, tags }) {
const res = await fetch(ROVELA_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${requireEnv('ROVELA_API_KEY')}`,
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify({
from: requireEnv('MAIL_FROM'),
to,
subject,
html,
text,
tags,
}),
})
const body = await res.json().catch(() => ({}))
if (!res.ok) {
throw new Error(`Rovela ${res.status}: ${body.message ?? 'send failed'}`)
}
return body // { object: 'email', id }
}
async function main() {
const day = utcDayKey()
const digest = await buildDailyDigest(day)
const to = requireEnv('DIGEST_TO').split(',').map((s) => s.trim())
// 每个产品每个 UTC 日一次逻辑发送 — 再次 Trigger Run 也安全
const result = await sendTransactional({
to,
subject: digest.subject,
html: digest.html,
text: digest.text,
idempotencyKey: `render-daily-digest:${day}`,
tags: { kind: 'render-daily-digest', day, platform: 'render' },
})
console.log(JSON.stringify({ ok: true, day, emailId: result.id, stats: digest.stats }))
}
main()
.then(() => process.exit(0))
.catch((err) => {
console.error(err)
process.exit(1)
})
Rovela 的 Idempotency-Key(最长 256 字符)会在 24 小时 内把相同键的重试变成返回原响应,而不是再发一封(Send Email API)。在 Render 上这很重要:你可以在 Runs 页 Trigger Run;被推迟的调度也可能再次跑你的逻辑;网络抖动也可能重试 fetch。
推荐键:
| 键 | 适用场景 |
|---|---|
render-daily-digest:{yyyy-mm-dd} | 每个 UTC 日一封运维摘要 |
render-hourly-ops:{yyyy-mm-dd}T{HH} | 小时快照(对齐 0 * * * *) |
render-weekly-report:{yyyy}-W{ww} | 每个 ISO 周一封创始人报告 |
render-customer-digest:{userId}:{yyyy-mm-dd} | 按用户摘要(每个收件人窗口一个键) |
若要发 大量 个性化摘要,按收件人循环并各自使用新键(或对最多 100 条非模板消息用 POST /emails/batch——batch 会按设计拒绝 template / topic_id,见 Send Email API)。控制并发;遇到 429 退避并用 同一 键重试。
Python 备选(干净退出)
# digest_cron.py
import json
import os
import sys
from datetime import datetime, timezone
import urllib.request
ROVELA_URL = "https://api.rovela.dev/emails"
def require_env(name: str) -> str:
v = os.environ.get(name)
if not v:
raise SystemExit(f"missing env {name}")
return v
def utc_day_key() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%d")
def build_daily_digest(day: str) -> dict:
stats = {"signups": 12, "paid": 3, "incidents": 1, "failedJobs": 0}
subject = f"Acme daily digest — {day}"
text = "\n".join(
[
f"Signups: {stats['signups']}",
f"Paid: {stats['paid']}",
f"Open incidents: {stats['incidents']}",
f"Failed jobs: {stats['failedJobs']}",
]
)
html = f"""
<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>
"""
return {"subject": subject, "html": html, "text": text, "stats": stats}
def send_transactional(*, to, subject, html, text, idempotency_key, tags):
payload = {
"from": require_env("MAIL_FROM"),
"to": to,
"subject": subject,
"html": html,
"text": text,
"tags": tags,
}
req = urllib.request.Request(
ROVELA_URL,
data=json.dumps(payload).encode(),
headers={
"Authorization": f"Bearer {require_env('ROVELA_API_KEY')}",
"Content-Type": "application/json",
"Idempotency-Key": idempotency_key,
},
method="POST",
)
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode())
def main() -> None:
day = utc_day_key()
digest = build_daily_digest(day)
to = [s.strip() for s in require_env("DIGEST_TO").split(",") if s.strip()]
result = send_transactional(
to=to,
subject=digest["subject"],
html=digest["html"],
text=digest["text"],
idempotency_key=f"render-daily-digest:{day}",
tags={"kind": "render-daily-digest", "day": day, "platform": "render"},
)
print(json.dumps({"ok": True, "day": day, "emailId": result.get("id"), "stats": digest["stats"]}))
if __name__ == "__main__":
try:
main()
except Exception as err:
print(err, file=sys.stderr)
raise SystemExit(1)
构建安装依赖后,把 Cron Job Command 设为 python digest_cron.py。
手动 Trigger Run 与被推迟的调度
- Runs 页的 Trigger Run 可在非调度时刻启动一次(调试很有用)。
- 若已有 active run,Trigger Run 会 先取消 它,再启动新的。
- 若 调度 时刻到来时仍有 run 在跑,Render 会 推迟 该次调度直到当前结束——不会并行开第二个实例。
因此「成功就退出」很重要,且 Idempotency-Key 必须对齐 日历窗口,而不是「进程启动的精确秒」。被推迟到 13:07 才跑的 13:00 UTC 摘要,仍应使用 render-daily-digest:2026-11-05,而不是会制造第二封逻辑邮件的时间戳。
故障排查
| 现象 | 检查 |
|---|---|
Rovela 400 点名某个域名 | MAIL_FROM 域名未在 Rovela 验证 |
Rovela 401 | Cron Job 或 environment group 上缺少 / 写错 ROVELA_API_KEY |
Rovela 429 | 退避;用 同一 Idempotency-Key 重试 |
| Run 永不结束 / 撞上 12h | 关闭连接池;缩小任务;长任务改 worker |
| 下一次调度变晚 | 上一 run 仍在 active — 更快退出或拆分工作 |
| Trigger Run 后出现重复邮件 | 键必须按窗口稳定(render-daily-digest:{day}),不要每次进程 uuid() |
非 2xx 时按原样记录 Rovela 的 message 字段(Quick Start)。生产投递状态优先用 webhook,而不是轮询 GET /emails/:id(Send Email API)。
清单
- Rovela 发信域名已验证;
sending_accesskey 在 Render 环境(或 group)中,不在 Git - Cron Job Schedule 为 UTC;成功时 Command 以
0退出 - 触发时做实时聚合;尽可能同时提供 HTML + text
Idempotency-Key对齐调度窗口(render-…:{day})- 不依赖 Cron Job 的 persistent disk
- 用 Runs 页做 Trigger Run / 看日志;用 Rovela 控制台或 webhook 看投递