Inngest Cron 事务邮件 + Rovela(2026):定时函数与按窗口的幂等键
把 Inngest cron 触发器(TZ=、jitter、step.run、扇出)接到 Rovela POST /emails:sending_access 仅服务端,并用按窗口的 Idempotency-Key,让重试不会重复发摘要邮件。
Inngest cron 负责定时 — Rovela 负责投递
Inngest 是一套耐久执行(durable execution)平台。除了事件驱动工作流,你还可以在 createFunction() 上用原生 cron 触发器声明定时函数——标准 unix-cron、可选的 TZ= 时区前缀,以及可选的 jitter,避免大量 cron 在同一秒踩踏。Inngest 保存日程、到期时唤醒你的函数,并在 serverless 超时与重新部署之间重试耐久的 step.run 工作。
这种形态适合摘要邮件、运维日报、试用到期提醒等事务邮件:正文依赖触发当下的应用或数据库状态(注册数、未付账单、失败任务),而不是可以提前几小时用 Rovela scheduled_at 冻住的内容。
当你的 Inngest step 调用 POST https://api.rovela.dev/emails 时,Rovela 负责入队、DKIM 签名与投递(Send Email API、Quick Start、Authentication)。Inngest 拥有 cron、耐久步骤与重试;你的函数拥有聚合逻辑、sending_access 密钥,以及按日程窗口稳定的 Idempotency-Key。Rovela 只负责投递。
本指南把 Inngest cron → step.run → Rovela 串起来:
- 用
triggers: [cron("…")](或{ cron, jitter })声明函数,并在/api/inngest上serve - 把
ROVELA_API_KEY(sending_access)、MAIL_FROM、收件人放在仅服务端密钥里——绝不进浏览器或公开 Git - 在耐久 step 内为当前日程窗口聚合摘要 / 报表
- 通过 Rovela 发送,并使用按窗口的
Idempotency-Key - 在 Inngest 重试、重叠 tick、step 重放时也不会重复发信
Rovela 不会创建 Inngest 应用、cron 触发器或签名密钥。 Inngest 负责 schedule → 调用 → 耐久步骤;你的函数负责查询 → 映射字段 → POST /emails;Rovela 负责投递。
与下列文章区分开:
- Upstash QStash Schedules Email — QStash HTTPS 日程 +
Upstash-Signature(不是 Inngest cron) - Supabase pg_cron Email — Postgres
pg_cron+pg_net→ Edge Function - Kubernetes CronJob Email —
batch/v1CronJob → Job Pod - DigitalOcean App Platform Jobs Email — App Platform
kind: SCHEDULEDJob - Heroku Scheduler Email — 粗粒度 Scheduler dyno
- Cloudflare Workers Cron Triggers Email — Workers
triggers.crons+scheduled() - Azure Functions Timer Email — Azure NCRONTAB
- GCP Cloud Scheduler Email — Cloud Scheduler HTTP/OIDC 或 Pub/Sub
- AWS EventBridge Scheduler Email — EventBridge Scheduler → Lambda
- Netlify Scheduled Functions Email — Netlify UTC cron
- Fly Scheduled Machines Email — Fly Machine schedules
- Render Cron Transactional Email — Render Cron Job 进程
- Railway Cron Transactional Email — Railway cron 服务
- Vercel Cron Digest Email — 平台 cron
GET生产路由 - GitHub Actions Transactional Email — workflow / CI 通知
- Scheduled Email Sending — 正文已知时用 Rovela
scheduled_at
也不同于 Inngest 的事件触发、一次性未来 ts 事件,以及 step.sleep / step.sleepUntil 延迟——本文只讲周期性 cron 触发器路径。
Inngest cron 做什么,Rovela 发什么
| 关注点 | 责任方 |
|---|---|
Cron 表达式、可选 TZ=IANA、可选 jitter(1s–5m) | Inngest cron 触发器 / cron() helper |
Serve 函数(serve() 于 /api/inngest 或 connect()) | 你的应用 + Inngest(Serving functions) |
耐久 step.run / 通过 step.sendEvent 扇出 | Inngest |
ROVELA_API_KEY(sending_access)服务端密钥 | 你的主机 / vault / 平台环境变量 |
| 按窗口查询 DB / API | 你的函数 steps |
| 渲染 HTML + text | 你的函数 |
POST /emails、DKIM、队列、退信 | Rovela |
Idempotency-Key = inngest-cron-daily-digest:{yyyy-mm-dd}(或小时 / 分钟桶) | 你的函数 |
Rovela 返回 200 表示已接受并入队,不是「已经进收件箱」(Quick Start)。
影响发信函数的 Inngest cron 约束
根据 Inngest 文档,把这些当作发信日程的硬要求:
- 用 cron 触发器声明。 在
createFunction()上使用triggers: [cron("0 9 * * *")]或对象形式{ cron: "0 * * * *", jitter: "5m" }(Crons、Create Function)。 - 五段 unix cron。 分、时、日、月、周。例如:
0 9 * * *每天 UTC 09:00;0 * * * *每小时;0 12 * * 5周五 12:00(schedules guide)。 - 默认时区 UTC。 需要本地墙钟时加
TZ=America/New_York等 IANA 前缀,例如TZ=Europe/Paris 0 12 * * 5(timezones)。 - 夏令时注意。 过渡时段附近,cron 可能跑 0/1/2 次。Inngest 不做特殊 DST 修正——需要稳定节奏时优先
TZ=UTC,并避开过渡小时(DST disclaimer)。 - 可选 jitter。 用
"1s"到"5m"的 jitter 打散负载,避免大量 cron 同一秒打到 Rovela 或你的数据库(jitter)。 - 必须 serve。 Inngest 要通过
serve()(常见/api/inngest)或connect()触达你的应用;否则 cron 函数不会跑(Serving)。 - 纯 cron 没有
event。 仅 cron 触发的定时函数不会收到event参数——用step/runId/logger(Create Function)。 - 同一函数上重叠的 cron 会去重。 多个会同时触发的 cron 字符串会折叠(Create Function)。
- 免费计划连续失败会暂停。 免费计划连续失败 20 次会自动暂停函数——监控失败的 cron,以免邮件静默停发(repeated failures)。
- 把 Rovela 发送放进
step.run。 耐久 step 会在重试间记忆成功结果;仍要传按窗口的 RovelaIdempotency-Key,防止重入再次打 HTTP 时重复发信。 sending_access仅服务端。 与INNGEST_SIGNING_KEY一起放在平台 env / vault——不要进客户端包或 Inngest 事件载荷(Authentication)。- 优先 HTTPS
POST /emails。 在 serverless / 耐久 step 里出站 SMTP 很糟糕(Email API vs SMTP)。
何时用 Inngest cron + POST /emails(而不是 scheduled_at)
在这些情况下用 Inngest cron → 即时发送:
- HTML 必须包含触发时的新鲜指标
- 你已在用 Inngest 工作流,希望日程与同一套耐久运行时放在一起
- 需要
step.sendEvent扇出大量个性化摘要,而不是一个超长循环 - 一封(或少量)事务邮件发往运行时查到的运维 / 创始人 / 客户地址
当正文已经正确、只需要「稍后再投」且不必跑任务时,用 Rovela scheduled_at(Scheduled Email Sending)。Inngest cron 与 scheduled_at 解决不同问题;本文是 Inngest 路径。
若要的是带签名校验目的地的 HTTP 消息总线调度器,用 QStash Schedules(Upstash QStash Schedules Email)。若日程应贴近某平台算力,用该平台 cron——见上文各篇。
前置条件
- Rovela 中已验证的发信域名。未验证的
from域会400(Quick Start)。 - 形如
re_…的 API key。cron 函数优先sending_access(Authentication)。 - 已安装 SDK(
inngest)的 Inngest 应用,服务端有INNGEST_SIGNING_KEY(若还发事件则需 event key)(Serving)。 - 已 serve 的 Inngest 端点(Next.js App Router
/api/inngest、Express、Hono 等)。 - 服务端环境变量:
ROVELA_API_KEY、MAIL_FROM、DIGEST_TO。
声明 cron 函数(TypeScript)
// inngest/client.ts
import { Inngest } from "inngest";
export const inngest = new Inngest({ id: "acme-app" });
每天 UTC 09:00 的日报:
// inngest/functions/daily-digest.ts
import { cron } from "inngest";
import { inngest } from "../client";
const ROVELA_URL = "https://api.rovela.dev/emails";
function requireEnv(name: 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
}
function utcHourKey(d = new Date()) {
return d.toISOString().slice(0, 13); // YYYY-MM-DDTHH
}
async function buildDailyDigest(day: string) {
// 替换为你对 Postgres / Redis / 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[];
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(() => ({}));
if (!res.ok) {
throw new Error(`Rovela ${res.status}: ${(body as any).message ?? "send failed"}`);
}
return body as { object: string; id: string };
}
export const dailyDigestEmail = inngest.createFunction(
{
id: "daily-digest-email",
triggers: [cron("0 9 * * *")],
},
async ({ step }) => {
const day = utcDayKey();
const idempotencyKey = `inngest-cron-daily-digest:${day}`;
const digest = await step.run("build-digest", async () => {
return buildDailyDigest(day);
});
const result = await step.run("send-rovela", async () => {
const to = requireEnv("DIGEST_TO")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
// 永远不要日志打印 ROVELA_API_KEY 或 INNGEST_SIGNING_KEY
console.log(JSON.stringify({ day, idempotencyKey }));
return sendTransactional({
to,
subject: digest.subject,
html: digest.html,
text: digest.text,
idempotencyKey,
tags: {
kind: "inngest-cron-daily-digest",
day,
platform: "inngest",
},
});
});
return { ok: true, emailId: result.id, day };
}
);
本地时区(工作日美东 09:00)并加 jitter 缓和尖峰:
export const weekdayOpsEmail = inngest.createFunction(
{
id: "weekday-ops-email",
triggers: [
{
cron: "TZ=America/New_York 0 9 * * 1-5",
jitter: "2m",
},
],
},
async ({ step }) => {
// 同样的 build + Rovela POST /emails;按你定义的本地/UTC 日做 key
await step.run("send-rovela", async () => {
/* ... */
});
}
);
每小时运维脉搏(UTC)与匹配的 Idempotency-Key:
export const hourlyOpsEmail = inngest.createFunction(
{
id: "hourly-ops-email",
triggers: [{ cron: "0 * * * *", jitter: "30s" }],
},
async ({ step }) => {
const hour = utcHourKey();
const idempotencyKey = `inngest-cron-hourly-ops:${hour}`;
await step.run("send-rovela", async () => {
/* build + POST /emails,带上 idempotencyKey */
return { idempotencyKey };
});
}
);
Serve 函数(Next.js App Router)
// app/api/inngest/route.ts
import { serve } from "inngest/next";
import { inngest } from "../../../inngest/client";
import { dailyDigestEmail, weekdayOpsEmail, hourlyOpsEmail } from "../../../inngest/functions/daily-digest";
export const { GET, POST, PUT } = serve({
client: inngest,
functions: [dailyDigestEmail, weekdayOpsEmail, hourlyOpsEmail],
});
在主机服务端环境设置 INNGEST_SIGNING_KEY(及相关密钥),以便 SDK 校验 Inngest 流量(Serving)。Express、Hono、Cloudflare Workers 等使用对应的 inngest/<framework> serve helper,路径模式同样是 /api/inngest。
扇出:大量个性化摘要
当一次 cron 要给数百用户发信时,不要在单个超长 step 里循环 POST /emails。用 step.sendEvent 扇出,再在事件触发的函数里按收件人发送(fan-out pattern):
import { cron } from "inngest";
import { inngest } from "../client";
export const prepareWeeklyDigest = inngest.createFunction(
{
id: "prepare-weekly-digest",
triggers: [cron("TZ=Europe/Paris 0 12 * * 5")],
},
async ({ step }) => {
const users = await step.run("load-users", async () => {
// return [{ id, email }, ...]
return [] as { id: string; email: string }[];
});
const events = users.map((user) => ({
name: "app/send.weekly.digest",
data: { user_id: user.id, email: user.email },
}));
await step.sendEvent("send-digest-events", events);
}
);
export const sendWeeklyDigest = inngest.createFunction(
{
id: "send-weekly-digest-email",
triggers: [{ event: "app/send.weekly.digest" }],
},
async ({ event, step }) => {
const { email, user_id } = event.data as { email: string; user_id: string };
const week = /* ISO week key */ "2026-W45";
const idempotencyKey = `inngest-cron-weekly-digest:${user_id}:${week}`;
await step.run("send-rovela", async () => {
// POST /emails,Idempotency-Key = idempotencyKey
return { email, user_id, idempotencyKey };
});
}
);
每个收件人用自己的按窗口 key。控制并发;遇到 Rovela 429 时抛错,让 Inngest 用同一 key 重试该 step。
按窗口的 Idempotency-Key
Rovela 的 Idempotency-Key(最长 256 字符)会让 24 小时内相同请求的重试返回原始响应,而不是再发一封(Send Email API)。对 Inngest 很重要,因为:
- 函数与 step 重试会在 5xx / 超时后重新进入
- 耐久
step.run的记忆有帮助,但新的 run id / 部分重放仍可能再次打到 HTTP - 调试时在 Inngest 控制台 Invoke 可能对同一日历窗口二次触发
- 可选 jitter 会打散开始时间,但 key 仍应绑定你意图的日程窗口(日 / 小时),而不是墙钟毫秒
推荐 key:
| Key | 适用场景 |
|---|---|
inngest-cron-daily-digest:{yyyy-mm-dd} | 每个 UTC 日一封运维摘要 |
inngest-cron-hourly-ops:{yyyy-mm-dd}T{HH} | 每小时快照(0 * * * *) |
inngest-cron-weekday-ops:{yyyy-mm-dd} | 工作日本地时摘要(按你意图的业务日) |
inngest-cron-weekly-report:{yyyy}-W{ww} | 每个 ISO 周一封创始人周报 |
inngest-cron-customer-digest:{userId}:{yyyy-mm-dd} | 扇出后的按用户摘要 |
大量个性化摘要时,每个收件人用新 key(或对最多 100 条非模板消息用 POST /emails/batch——batch 按设计拒绝 template / topic_id(Send Email API))。
挂载密钥并验证日程
- 部署带
/api/inngest(或你的servePath)的应用,确认INNGEST_SIGNING_KEY与ROVELA_API_KEY(sending_access)仅在服务端。 - 同步 / 部署,使 Inngest 注册 cron 函数(本地用 Dev Server;云端随部署同步)。
- 等待下一次 cron tick,或在保持窗口 key 稳定的前提下用 Inngest 的 invoke / 测试工具。
- 在函数日志中查看
idempotencyKey+ RovelaemailId。可选再与 Rovela webhook 关联。
生产发信优先 sending_access——不要用 full_access(Authentication)。永远不要日志打印 API key 或签名密钥。轮换 Rovela:先创建再吊销;Inngest 签名密钥按控制台指引轮换。
故障模式与处理
| 现象 | 可能原因 | 处理 |
|---|---|---|
| Cron 从不跑 | 未 serve / 路径错误 / 缺签名密钥 | 确认 /api/inngest 已注册函数;检查 INNGEST_SIGNING_KEY(Serving) |
Rovela 401 | ROVELA_API_KEY 错误或缺失 | 修好服务端密钥;确认 sending_access |
Rovela 400 点名域名 | MAIL_FROM 域未验证 | 在 Rovela Domains 验证域名 |
Rovela 429 | 限流 | 在 step 中抛错让 Inngest 重试;保持同一 Idempotency-Key |
| 重复摘要 | 重试 / 手动 invoke 且无稳定 key | 用按窗口 key;24h 内 Idempotency-Key 会折叠重复 |
| DST 附近双发 | 本地 TZ= 落在过渡小时 | 优先 TZ=UTC 或避开过渡小时(DST) |
| 函数被暂停 | 免费计划:连续失败 20 次 | 修好错误,在控制台取消暂停(failures) |
| 长循环超时 | 在一个 step 里给很多人发信 | 用 step.sendEvent 扇出 |
| 密钥进 Git / 客户端 | 密钥被提交或打包 | 只放在平台密钥中 |
生产检查清单
- 已验证 Rovela 发信域;
MAIL_FROM匹配 ROVELA_API_KEY为sending_access,仅服务端- Inngest cron 表达式有文档(非 UTC 时写明
TZ=);可选 jitter 有说明 - 函数在
/api/inngest(或显式servePath)serve,并配置INNGEST_SIGNING_KEY - Rovela 发送放在
step.run,并用按窗口的Idempotency-Key - 大收件人列表用扇出;按用户 key
- 日志有成功的
emailId,不打印密钥 - 对连续 cron 失败告警(尤其免费计划自动暂停)
- 可选:对摘要收件人配置 Rovela
email.bounced/email.complainedwebhook
FAQ
Inngest 能不经过我的函数直接调 Rovela 吗?没有实用意义——cron 必须跑你的代码,才能聚合实时数据并把 sending_access 留在本地。Rovela 不托管 Inngest 触发器。
要不要在 cron step 里用 Rovela scheduled_at?通常不要。若 Inngest 已在发送时刻带着实时正文触发,直接 POST /emails。当你现在收下消息、希望 Rovela 稍后再投且不再唤醒时,才用 scheduled_at(Scheduled Email Sending)。
Inngest cron vs QStash Schedules vs 平台 cron?日程应贴近耐久工作流与扇出时用 Inngest;要 HTTP 原生、带签名校验目的地的调度器用 QStash(Upstash QStash Schedules Email);日程应贴近某平台算力时用 Vercel/Render/K8s cron。
step.sleep 能代替「稍后发送」吗?step.sleep / step.sleepUntil 是让已有 run 稍后继续——适合事件之后,不是周期性日历 cron 的替代品(schedules guide)。本文讲的是周期性 cron 触发器。
sending_access 覆盖什么?足以通过 POST /emails / batch 入队邮件。读消息、改期、取消需要 full_access 或控制台会话(Send Email API、Authentication)。cron 函数保持 sending_access。
200 是否表示创始人已经收到摘要?否——只表示 Rovela 已接受并入队。投递状态用 GET /emails/:id、事件或 webhook(Quick Start)。
为什么既要 Inngest 重试又要 Rovela Idempotency-Key?Inngest 在你的代码失败时重试函数 / step;Rovela 的 key 在同一窗口内 HTTP 成功两次(或 Rovela 已接受后超时)时折叠重复发送。两层都要。
把 cron 发出去,让 Rovela 投递
声明 Inngest cron 触发器,把实时聚合与 POST /emails 放进耐久 step,把 Idempotency-Key 限定在日程窗口,并把 sending_access 留在服务端。Inngest 准时唤醒;Rovela 投递事务邮件。