Cloudflare Workers Cron Triggers 事务邮件 + Rovela(2026):triggers.crons、scheduled() 与按窗口的幂等键
用 Cloudflare Workers Cron Triggers(Wrangler triggers.crons 五段 UTC cron + scheduled())对接 Rovela POST /emails。经 wrangler secret 存放 ROVELA_API_KEY,触发时聚合实时摘要,并用 controller.scheduledTime 生成按窗口的 Idempotency-Key。区别于 HTTP Workers 发信以及 Azure/GCP/AWS/Netlify/Fly/Render/Railway/Vercel 调度。
Cron Triggers 管 UTC 调度 —— Rovela 负责投递
Cloudflare Workers Cron Triggers 把五段 cron 表达式映射到 Worker 的 scheduled() 处理函数。这种形态适合摘要、运维报表,以及正文必须依赖触发时刻实时状态的事务邮件——注册数、失败任务、未付发票——而不是你能提前几小时用 Rovela scheduled_at 冻住的内容。
当 Worker 调用 POST https://api.rovela.dev/emails 时,Rovela 负责入队、DKIM 签名与投递(发送邮件 API、快速开始、鉴权)。Cloudflare 负责 UTC 调度、边缘 isolate,以及加密密钥(wrangler secret put)。你的 Worker 负责聚合数据,并生成按调度窗口稳定的 Idempotency-Key(优先用 controller.scheduledTime)。Rovela 只负责投递。
本指南把 Cloudflare Workers Cron Triggers → Rovela 串起来:
- 用 Wrangler secrets / vars 存放
ROVELA_API_KEY(及相关配置)——绝不写进源码 - 在
wrangler.toml/wrangler.jsonc声明triggers.crons,并实现scheduled() - 为当前调度窗口聚合摘要 / 报表
- 经 Rovela 发送,并用
controller.scheduledTime(可选叠加controller.cron)生成窗口级Idempotency-Key - 本地试跑与重新部署时,同一窗口不会重复发信
Rovela 不会创建 Worker、Cron Triggers 或 Wrangler 项目。 Cloudflare 负责 cron → scheduled()。你的 Worker 负责查询 → 映射字段 → POST /emails。Rovela 负责投递。
与以下文章区分:
- Cloudflare Workers 事务邮件 —— HTTP /
fetch请求处理(OTP、魔法链接、回执),不是 Cron Triggers - Azure Functions Timer 邮件 —— Azure NCRONTAB(秒字段在前)+ App Settings / Key Vault
- GCP Cloud Scheduler 邮件 —— 五段 unix-cron / groc + HTTP/OIDC 或 Pub/Sub
- AWS EventBridge Scheduler 邮件 —— 六段 AWS cron /
rate/at+ Lambda - Netlify Scheduled Functions 邮件 —— Netlify UTC cron + 较短函数时限
- Fly Scheduled Machines 邮件 —— 粗粒度 Machine 间隔
- Render Cron 事务邮件 —— 必须退出的 Cron Job 进程
- Railway Cron 事务邮件 —— Railway cron 服务
- Vercel Cron 摘要邮件 —— 对生产路由的 HTTP
GET+CRON_SECRET - GitHub Actions 事务邮件 —— workflow /
run_id在 CI 事件上通知 - 定时发送邮件 —— 正文已知时用 Rovela
scheduled_at
也不同于 Cloudflare Email Workers / Email Routing(在域名上收信或路由),以及 Workers Workflows 的 schedules(另一套 Workflows 绑定)。本文只讲经典的 Worker scheduled() + triggers.crons 路径。
Cron Triggers 做什么,Rovela 发什么
| 事项 | 归属 |
|---|---|
triggers.crons 中的五段 cron(UTC) | Wrangler / Cron Triggers(文档) |
调用 scheduled() | Workers 运行时(Scheduled Handler) |
加密的 ROVELA_API_KEY | wrangler secret put → env.ROVELA_API_KEY |
| 查询 DB / API / KV / D1 取窗口数据 | 你的 Worker |
| 渲染 HTML + text | 你的 Worker |
POST /emails、DKIM、队列、退信 | Rovela |
Idempotency-Key = cf-cron-{kind}:{scheduledTime} | 你的 Worker |
Rovela 返回 200 表示已接受并入队,不等于「已经进收件箱」(快速开始)。
影响发信任务的 Cloudflare 约束
根据 Cloudflare 文档,把下列项当作发信 cron Worker 的硬条件:
- 仅 UTC。 Cron Triggers 按 UTC 执行(Cron Triggers)。本地「美东 09:30」需自己换算进表达式。
- 五段字段。 分、时、日、月、周;并支持大量类似 Quartz 的扩展(
L、W、#、星期缩写)。星期为 1 = Sunday … 7 = Saturday(与部分 Linux cron 不同)。为避免歧义,优先用MON–SUN(Supported cron expressions)。 - 用 Wrangler 管理的 Worker,调度应只写在 Wrangler 配置里。 放在
[triggers] crons = [...]/"triggers": { "crons": [...] }。部署时 Wrangler 会用你提交的数组替换旧 Cron Triggers。crons = []清空全部;省略triggers/crons则保留当前已部署触发器(configuration)。 - 传播延迟。 增删改 Cron Trigger 可能需要最多约 15 分钟才传到全球网络。
controller.cron与controller.scheduledTime。cron是触发本次事件的完整表达式字符串(比较时必须逐字符一致,含空格)。scheduledTime是事件计划执行时间的 epoch 毫秒(不一定等于 handler 实际开始墙钟)——用new Date(controller.scheduledTime)解析(Scheduled Handler)。- 多 cron、同一 handler。 多个表达式共用
scheduled();用switch (controller.cron)分支。 - 时长与 CPU。 Cron 调用有 15 分钟 墙钟上限。Cron 的 CPU 时间按套餐不同(Free 较短;Paid 更长,且小时级间隔上下限不同)——等待
fetch()/ 网络不计入 CPU(Workers limits)。 - 账号触发器上限。 Workers Free 每账号 5 个 Cron Triggers;Paid 250 个(limits)。
ctx.waitUntil。 运行时本就会等待scheduled()返回的 Promise(直到时长上限)。并发副作用,或希望某条 Promise 的结果记入 Past Cron Events 时再用waitUntil(Scheduled Handler)。- 本地测试。
wrangler dev/cf dev暴露/cdn-cgi/local/scheduled(可选?format=json、?cron=…、?time=…),无需等 UTC(Test Cron Triggers)。 - 密钥只留在服务端。 生产用 Rovela
sending_access,经wrangler secret put注入——不要放进浏览器包或公开仓库(鉴权)。 - 出站 SMTP 不合适。 优先走 443 上的 HTTPS
POST /emails(Email API vs SMTP)。
何时用 Cron Triggers + POST /emails(而不是 scheduled_at)
在下列情况用 Cron Triggers → 即时发送:
- HTML 必须在触发时包含新鲜指标
- 你已在 Workers 上跑计算,希望 Wrangler、密钥与调度同属一个项目
- 需要真正的 UTC cron(工作日早晨、每 15 分钟),而不是提前冻住正文
- 少量事务信发给运维 / 创始人 / 运行时查到的客户地址
当正文已经正确、只需要「稍后再投」且不必跑作业时,用 Rovela scheduled_at(定时发送邮件)。Cron Triggers 与 scheduled_at 解决不同问题;本文是 Cloudflare 调度路径。
发信由请求驱动(注册 OTP、重置密码)时,用普通 HTTP Worker(fetch)——见 Cloudflare Workers 事务邮件。
前置条件
- Rovela 中已验证的发信域名。未验证的
from域会以400拒绝(快速开始)。 - 形如
re_…的 API key。Worker 上优先sending_access(鉴权)。 - 带 Wrangler(
wrangler.toml或wrangler.jsonc)的 Workers 项目,且模块 Worker 导出scheduled。 - 密钥:
npx wrangler secret put ROVELA_API_KEY(可选 vars:MAIL_FROM/DIGEST_TO)。 - 五段 UTC cron——例如
30 13 * * MON-FRI表示美东标准时约 09:30 的窗口(夏令时需自行调整)。
用 Wrangler 管理密钥
# 在 Worker 项目目录
npx wrangler secret put ROVELA_API_KEY
# 按提示粘贴 re_… sending_access 密钥
# 非敏感配置可放 wrangler.toml / wrangler.jsonc 的普通 vars
Wrangler 配置示例(TOML):
name = "daily-digest-email"
main = "src/index.js"
compatibility_date = "2026-11-01"
[vars]
MAIL_FROM = "Acme Reports <reports@mail.example.com>"
DIGEST_TO = "founders@example.com"
DIGEST_KIND = "daily"
[triggers]
# 工作日 13:30 UTC —— 仅为示例;请有意识地选择 UTC 墙钟时间
crons = [ "30 13 * * MON-FRI" ]
等价 JSONC 草图:
{
"name": "daily-digest-email",
"main": "src/index.js",
"compatibility_date": "2026-11-01",
"vars": {
"MAIL_FROM": "Acme Reports <reports@mail.example.com>",
"DIGEST_TO": "founders@example.com",
"DIGEST_KIND": "daily"
},
"triggers": {
"crons": ["30 13 * * MON-FRI"]
}
}
本地把密钥放进 .dev.vars(切勿提交真实密钥)。生产从加密 secret 绑定读取 env.ROVELA_API_KEY。生产发信方保持 sending_access——不要用 full_access(鉴权)。永远不要打印 API key。
需要更安静的预发节奏时,把调度写在 [env.dev.triggers] / env.dev.triggers。
scheduled() 与按窗口的幂等键
// src/index.js
const ROVELA_URL = 'https://api.rovela.dev/emails';
function requireEnv(env, name) {
const v = 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
}
function utcHourKey(d = new Date()) {
return d.toISOString().slice(0, 13); // YYYY-MM-DDTHH
}
/** 优先用 controller.scheduledTime —— 同一发生成的重试 / 本地重放保持稳定。 */
function windowKeyFromController(controller, kind) {
if (controller && typeof controller.scheduledTime === 'number') {
return `cf-cron-${kind}:${controller.scheduledTime}`.slice(0, 256);
}
return kind === 'hourly'
? `cf-cron-hourly-ops:${utcHourKey()}`
: `cf-cron-daily-digest:${utcDayKey()}`;
}
async function buildDailyDigest(day) {
// 换成你对 D1 / KV / Hyperdrive / 源站 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(env, { to, subject, html, text, idempotencyKey, tags }) {
const res = await fetch(ROVELA_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${requireEnv(env, 'ROVELA_API_KEY')}`,
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify({
from: requireEnv(env, '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 sendDigest(controller, env) {
const kind = env.DIGEST_KIND || 'daily';
const scheduled = new Date(controller.scheduledTime);
const day = utcDayKey(scheduled);
const digest = await buildDailyDigest(day);
const to = requireEnv(env, 'DIGEST_TO')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const idempotencyKey = windowKeyFromController(controller, kind);
console.log(
JSON.stringify({
kind,
day,
cron: controller.cron,
scheduledTime: controller.scheduledTime,
idempotencyKey,
}),
);
return sendTransactional(env, {
to,
subject: digest.subject,
html: digest.html,
text: digest.text,
idempotencyKey,
tags: {
kind: `cf-cron-${kind}-digest`,
day,
platform: 'cloudflare-workers-cron',
cron: controller.cron,
},
});
}
export default {
async scheduled(controller, env, ctx) {
// 可选:多个 cron 共用此 Worker 时分支
// switch (controller.cron) { case "30 13 * * MON-FRI": ... }
const result = await sendDigest(controller, env);
console.log(JSON.stringify({ ok: true, emailId: result.id }));
// 单次 await 时 waitUntil 可选 —— 运行时会等待 scheduled() 的 Promise。
// 需要并发副作用并记入 Past Events 时再用 ctx.waitUntil(extraWork)。
},
};
Rovela 的 Idempotency-Key(最长 256 字符)会在 24 小时内把相同发送请求的重试返回为原响应,而不是再发一封(发送邮件 API)。在 Cloudflare 上这很重要,因为:
- 本地
/cdn-cgi/local/scheduled冒烟测试若传入相同time=,会再次进入同一逻辑窗口 - 重新部署与重叠试跑不应给创始人发双份
- 对失败的 scheduled 调用,侧效应应按至少一次来设计——用
scheduledTime把发送做成幂等
推荐键:
| 键 | 适用场景 |
|---|---|
cf-cron-daily-digest:{scheduledTime} | 与 Cron Trigger 发生最强对齐 |
cf-cron-daily-digest:{yyyy-mm-dd} | 每个 UTC 日一封运维摘要(一天多次触发时较弱) |
cf-cron-hourly-ops:{yyyy-mm-dd}T{HH} | 小时快照(0 * * * *) |
cf-cron-{kind}:{cron}:{scheduledTime} | 同一 Worker 上多个 cron 共享 kind |
cf-cron-customer-digest:{userId}:{yyyy-mm-dd} | 按用户摘要(每个收件人窗口一把键) |
若要发大量个性化摘要,为每个收件人使用新键循环(或对最多 100 封非模板消息用 POST /emails/batch——batch 会故意拒绝 template / topic_id(发送邮件 API))。在 CPU / 子请求限制内控制并发;遇到 429 退避并用同一键重试。
TypeScript 草图
export interface Env {
ROVELA_API_KEY: string;
MAIL_FROM: string;
DIGEST_TO: string;
DIGEST_KIND?: string;
}
export default {
async scheduled(
controller: ScheduledController,
env: Env,
ctx: ExecutionContext,
): Promise<void> {
// 调用与 JS 示例相同的 sendDigest 逻辑
await sendDigest(controller, env);
},
};
同一 Worker 上多个 Cron Triggers
[triggers]
crons = [ "*/15 * * * *", "30 13 * * MON-FRI" ]
export default {
async scheduled(controller, env, ctx) {
switch (controller.cron) {
case '*/15 * * * *':
await sendDigest({ ...controller /* 按小时/运维处理 */ }, env);
break;
case '30 13 * * MON-FRI':
await sendDigest(controller, env);
break;
default:
console.log('unhandled cron', controller.cron);
}
},
};
controller.cron 必须与配置字符串完全一致,包括空格(Scheduled Handler)。
不等 UTC 的本地测试
npx wrangler dev
# 另开终端 —— 使用 Wrangler 打印的端口(常为 8787)
curl "http://localhost:8787/cdn-cgi/local/scheduled?format=json"
curl "http://localhost:8787/cdn-cgi/local/scheduled?cron=30+13+*+*+MON-FRI&time=1730806200000&format=json"
传入稳定的 time= epoch 毫秒,可固定 controller.scheduledTime,从而在多次本地「同一发生」试跑时保持 Rovela Idempotency-Key 不变。
故障排查
| 现象 | 检查什么 |
|---|---|
Rovela 400 提到域名 | MAIL_FROM 域未在 Rovela 验证 |
Rovela 401 | 缺少 / 错误的 ROVELA_API_KEY;该环境未设置 secret |
Rovela 429 | 退避;用同一 Idempotency-Key 重试 |
| 本地重测后重复邮件 | 键必须用 scheduledTime(或日/小时窗口),不要每次 crypto.randomUUID() |
| 部署后 cron 不触发 | 等待最多约 15 分钟传播;确认已导出 scheduled() 且部署环境 triggers.crons 非空 |
| 触发时间不对 | 记住是 UTC;Cloudflare 上星期 1 = Sunday |
| 撞上 Free 计划触发器上限 | Free 每账号最多 5 个 Cron Triggers —— 合并表达式或升级 |
CPU 超限(exceededCpu) | isolate 内同步过重;把 I/O 放到 fetch/绑定(网络等待不计 CPU)或简化聚合 |
| 代码里需要投递状态 | 优先用 Rovela webhooks;GET /emails/:id/events 需要 full_access,而 sending_access 只能发信 |
非 2xx 时按原样记录 Rovela 的 message 字段(快速开始)。生产投递状态优先用 webhook,而不是轮询(发送邮件 API、鉴权)。控制台 Past Cron Events 可查看近期 scheduled 调用(Cron Triggers)。
清单
- 已验证 Rovela 发信域;经
wrangler secret put注入sending_access,不进 Git - 有意识地用 UTC 设置
triggers.crons;理解星期编号 / 缩写 - 模块 Worker 导出
scheduled(controller, env, ctx) - 触发时实时聚合;尽量同时设置 HTML + text
Idempotency-Key绑定controller.scheduledTime(或日/小时窗口)- 依赖生产 UTC 触发前,先做本地
/cdn-cgi/local/scheduled冒烟 - Workers Observability / Past Cron Events 看调用健康;Rovela 控制台或 webhook 看投递
相关阅读
- Cloudflare Workers 事务邮件(HTTP / 请求路径)
- Azure Functions Timer 邮件
- GCP Cloud Scheduler 邮件
- AWS EventBridge Scheduler 邮件
- Netlify Scheduled Functions 邮件
- Fly Scheduled Machines 邮件
- Render Cron 事务邮件
- Railway Cron 事务邮件
- Vercel Cron 摘要邮件
- GitHub Actions 事务邮件
- 定时发送邮件
- Email API vs SMTP
- 发送邮件 API
- 鉴权
- Cron Triggers
- Scheduled Handler
- Workers limits
- Wrangler configuration — triggers