← 返回博客

Inngest Cron 事务邮件 + Rovela(2026):定时函数与按窗口的幂等键

把 Inngest cron 触发器(TZ=、jitter、step.run、扇出)接到 Rovela POST /emails:sending_access 仅服务端,并用按窗口的 Idempotency-Key,让重试不会重复发摘要邮件。

更新于 2026年11月5日
Inngest Cron 事务邮件 + Rovela(2026):定时函数与按窗口的幂等键

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 串起来:

  1. 用 triggers: [cron("…")](或 { cron, jitter })声明函数,并在 /api/inngest 上 serve
  2. 把 ROVELA_API_KEY(sending_access)、MAIL_FROM、收件人放在仅服务端密钥里——绝不进浏览器或公开 Git
  3. 在耐久 step 内为当前日程窗口聚合摘要 / 报表
  4. 通过 Rovela 发送,并使用按窗口的 Idempotency-Key
  5. 在 Inngest 重试、重叠 tick、step 重放时也不会重复发信

Rovela 不会创建 Inngest 应用、cron 触发器或签名密钥。 Inngest 负责 schedule → 调用 → 耐久步骤;你的函数负责查询 → 映射字段 → POST /emails;Rovela 负责投递。

与下列文章区分开:

也不同于 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 会在重试间记忆成功结果;仍要传按窗口的 Rovela Idempotency-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——见上文各篇。

前置条件

  1. Rovela 中已验证的发信域名。未验证的 from 域会 400(Quick Start)。
  2. 形如 re_… 的 API key。cron 函数优先 sending_access(Authentication)。
  3. 已安装 SDK(inngest)的 Inngest 应用,服务端有 INNGEST_SIGNING_KEY(若还发事件则需 event key)(Serving)。
  4. 已 serve 的 Inngest 端点(Next.js App Router /api/inngest、Express、Hono 等)。
  5. 服务端环境变量: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))。

挂载密钥并验证日程

  1. 部署带 /api/inngest(或你的 servePath)的应用,确认 INNGEST_SIGNING_KEY 与 ROVELA_API_KEY(sending_access)仅在服务端。
  2. 同步 / 部署,使 Inngest 注册 cron 函数(本地用 Dev Server;云端随部署同步)。
  3. 等待下一次 cron tick,或在保持窗口 key 稳定的前提下用 Inngest 的 invoke / 测试工具。
  4. 在函数日志中查看 idempotencyKey + Rovela emailId。可选再与 Rovela webhook 关联。

生产发信优先 sending_access——不要用 full_access(Authentication)。永远不要日志打印 API key 或签名密钥。轮换 Rovela:先创建再吊销;Inngest 签名密钥按控制台指引轮换。

故障模式与处理

现象可能原因处理
Cron 从不跑未 serve / 路径错误 / 缺签名密钥确认 /api/inngest 已注册函数;检查 INNGEST_SIGNING_KEY(Serving)
Rovela 401ROVELA_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.complained webhook

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 投递事务邮件。