← 返回博客

Trigger.dev 定时任务事务邮件 + Rovela(2026):schedules.task 与按窗口的幂等键

用 Trigger.dev schedules.task(声明式 cron 或命令式 schedules.create)对接 Rovela POST /emails:sending_access 仅服务端,按调度窗口设置 Idempotency-Key,适合 digest 与多租户提醒。

更新于 2026年11月5日
Trigger.dev 定时任务事务邮件 + Rovela(2026):schedules.task 与按窗口的幂等键

Trigger.dev 负责定时调度 — Rovela 负责投递邮件

Trigger.dev 是带一等公民定时任务的后台作业平台:用 schedules.task() 定义任务,挂上声明式 cron(在 dev / deploy 时同步)和/或通过 schedules.create() / 控制台创建的命令式日程,Trigger.dev 会按日历唤醒你的 run 处理函数。载荷里包含 timestamp、lastTimestamp、timezone、scheduleId、externalId 以及接下来的 upcoming 运行时间 —— 足够给 digest 和多租户提醒做幂等键,而不必自己造时钟。

这种形态适合日报、运维报告、试用到期提醒等在触发时刻依赖实时应用 / 数据库状态的事务邮件 —— 注册数、未付发票、失败作业 —— 而不是几小时前就能用 Rovela scheduled_at 冻结的正文。

当你的 Trigger.dev 任务调用 POST https://api.rovela.dev/emails 时,Rovela 负责入队、DKIM 签名与投递(发送邮件 API、快速开始、鉴权)。Trigger.dev 拥有日程、重试与运行隔离;你的任务拥有聚合逻辑、sending_access 密钥,以及按调度窗口稳定的 Idempotency-Key。Rovela 只负责投递。

本指南串联 Trigger.dev 定时任务 → run → Rovela,实现:

  1. 用声明式 cron(UTC 字符串或 { pattern, timezone, environments, window })声明 schedules.task(),和/或用 schedules.create({ task, cron, deduplicationKey, … }) 挂命令式日程
  2. 把 ROVELA_API_KEY(sending_access)、MAIL_FROM、收件人放在仅服务端密钥中 —— Trigger.dev 环境变量 / vault,绝不进浏览器或公开 Git 树
  3. 在任务 run 内按当前调度窗口聚合 digest / 报告
  4. 用窗口级 Idempotency-Key 调用 Rovela 发送
  5. 在 Trigger.dev 重试、重叠 tick、控制台「Test schedule」下仍不重复发信

Rovela 不会创建 Trigger.dev 项目、日程或 API 密钥。 Trigger.dev 拥有 日程 → 调用 → 可靠任务运行;你的任务拥有 查询 → 映射字段 → POST /emails;Rovela 拥有投递。

与以下文章角度不同:

也不同于 Trigger.dev 的一次性延迟任务(delay / 未来触发)—— 本文只讲周期性 scheduled / cron 路径(Scheduled tasks)。

Trigger.dev 日程做什么,Rovela 发什么

关注点责任方
Cron 表达式、IANA 时区、可选 window、声明式 vs 命令式Trigger.dev schedules.task / schedules API
在 dev / deploy 同步声明式 cron;用 deduplicationKey 创建/更新命令式日程Trigger.dev CLI + SDK
任务 run 载荷(timestamp、scheduleId、externalId、upcoming 等)Trigger.dev
ROVELA_API_KEY(sending_access)服务端密钥你的 Trigger.dev 环境 / vault
按窗口查询 DB / API你的任务 run
渲染 HTML + text你的任务
POST /emails、DKIM、队列、退信Rovela
Idempotency-Key = trigger-dev-cron-daily-digest:{yyyy-mm-dd}(或小时 / 租户桶)你的任务

Rovela 返回 200 表示已接受并入队,不等于「已进收件箱」(快速开始)。

影响发信任务的 Trigger.dev 日程约束

根据 Trigger.dev 文档,发信类日程请把这些当硬约束:

  • 用 schedules.task() 定义。 普通 task() 不够挂 cron —— 要用 schedules.task({ id, run, … })(Scheduled tasks)。
  • 至少挂一个 schedule。 在加上声明式 cron 和/或命令式日程之前,任务不会触发。
  • 声明式 cron 随 CLI 同步。 在任务上写 cron: "0 */2 * * *"(UTC 字符串)或 cron: { pattern, timezone, environments, window };跑 dev 或 deploy 时同步。声明式日程由代码拥有 —— 不能在控制台编辑(declarative cron)。
  • 命令式日程需要 deduplicationKey。 schedules.create({ task, cron, deduplicationKey, timezone?, externalId?, window? }) 按 key upsert(按项目而非按环境 —— 跨环境创建时把环境名放进 key)(Create Schedule)。
  • 五段 cron(无秒)。 分、时、日、月、星期;支持 L 表示月末 / 最后一个星期几(Supported cron syntax)。
  • 默认时区 UTC。 声明式 cron.timezone 或命令式 timezone 使用 IANA 区域(如 America/New_York、Asia/Tokyo),墙钟时间会跟随夏令时。
  • 可选 window 打散负载。 绝对时长("30m"、"2h")或百分比("50%");"0m" / "0%" 表示严格按 cron。新日程默认 60 分钟窗口,除非覆盖(windows)。
  • 免费计划频率限制。 免费组织拒绝高于每小时一次的 cron,并套用至少 60 分钟窗口 —— 升级 / 祖父条款以文档为准。
  • 开发 vs 生产唤醒规则。 开发环境日程仅在 dev CLI 运行中才会触发。Staging/Production 只触发当前部署(最新版本)中的任务 —— 旧部署的日程不会继续开火(When schedules won’t trigger)。
  • 可选 ttl。 若上一轮仍在执行,让过期排队中的运行失效(ttl: "5m"),避免堆积过时 digest。
  • 用 externalId 做多租户。 命令式日程可设 externalId(用户/组织 id);在 run 里读取它来个性化邮件并按租户做幂等键。
  • sending_access 仅服务端。 把 ROVELA_API_KEY 放在 Trigger.dev 环境变量 —— 绝不进客户端包或日程元数据(鉴权)。
  • 优先 HTTPS POST /emails。 从后台任务直接出站 SMTP 并不合适(Email API vs SMTP)。

何时用 Trigger.dev 日程 + POST /emails(而不是 scheduled_at)

在以下情况使用 Trigger.dev cron → 实时发送:

  • HTML 必须在触发时包含最新指标
  • 你已在 Trigger.dev 跑后台任务,希望日程与同一运行时相邻
  • 需要通过命令式日程 + externalId 做按用户循环提醒
  • 一两封事务邮件发给运行时查到的运维 / 创始人 / 客户地址

当正文已经正确、只需要「稍后投递」且不必再跑作业时,用 Rovela scheduled_at(定时发送邮件)。Trigger.dev 日程与 scheduled_at 解决不同问题;本文是 Trigger.dev 路径。

若日程应落在 Inngest 持久步骤 / fan-out 旁,用 Inngest cron(Inngest Cron 事务邮件)。若要 HTTP 消息总线调度器,用 QStash(Upstash QStash Schedules 邮件)。若日程应落在某云主机算力旁,见上文各平台 cron 专文。

前置条件

  1. Rovela 上已验证发信域名。未验证的 from 域名会返回 400(快速开始)。
  2. 形如 re_… 的 API 密钥。定时任务优先 sending_access(鉴权)。
  3. Trigger.dev 项目,安装 @trigger.dev/sdk、CLI(npx trigger.dev@latest),任务放在配置的 trigger 目录(常见为 /trigger)(Scheduled tasks)。
  4. Trigger.dev 环境变量:ROVELA_API_KEY、MAIL_FROM、DIGEST_TO(以及任务需要的 DB URL)。
  5. 生产环境:成功 deploy,以便声明式日程同步,且只有当前版本会触发。

声明定时任务(TypeScript)

每天 09:00 UTC 的日报,使用声明式 cron:

// trigger/daily-digest-email.ts
import { schedules } from "@trigger.dev/sdk";

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 = schedules.task({
  id: "daily-digest-email",
  // 每天 09:00 UTC
  cron: "0 9 * * *",
  // 可选:上一轮仍在跑时丢弃过时运行
  // ttl: "30m",
  run: async (payload) => {
    // payload.timestamp 是日程计划触发时间(UTC Date)
    const day = utcDayKey(payload.timestamp);
    const idempotencyKey = `trigger-dev-cron-daily-digest:${day}`;

    const digest = await buildDailyDigest(day);
    const to = requireEnv("DIGEST_TO")
      .split(",")
      .map((s) => s.trim())
      .filter(Boolean);

    // 绝不打印 ROVELA_API_KEY 或 Trigger.dev 密钥
    console.log(
      JSON.stringify({
        day,
        idempotencyKey,
        scheduleId: payload.scheduleId,
        timezone: payload.timezone,
      })
    );

    const result = await sendTransactional({
      to,
      subject: digest.subject,
      html: digest.html,
      text: digest.text,
      idempotencyKey,
      tags: {
        kind: "trigger-dev-cron-daily-digest",
        day,
        platform: "trigger.dev",
        scheduleId: payload.scheduleId,
      },
    });

    return { ok: true, emailId: result.id, day };
  },
});

本地时区 + 显式 window(在名义 cron 时间之后打散):

export const tokyoMorningOps = schedules.task({
  id: "tokyo-morning-ops-email",
  cron: {
    pattern: "0 5 * * *",
    timezone: "Asia/Tokyo",
    environments: ["PRODUCTION", "STAGING"],
    // 在东京时间 05:00 之后 30 分钟内打散
    window: "30m",
  },
  run: async (payload) => {
    // Idempotency-Key 窗口优先用 payload.timestamp(名义 cron),
    // 不要用 window 偏移后的墙钟毫秒。
    const day = payload.timestamp.toLocaleDateString("en-CA", {
      timeZone: payload.timezone,
    }); // 日程时区下的 YYYY-MM-DD
    const idempotencyKey = `trigger-dev-cron-tokyo-ops:${day}`;
    // build + POST /emails,Idempotency-Key = idempotencyKey
    return { idempotencyKey, upcoming: payload.upcoming?.slice(0, 3) };
  },
});

每小时运维脉冲(UTC)—— 若在免费计划,注意最小间隔 / 窗口规则:

export const hourlyOpsEmail = schedules.task({
  id: "hourly-ops-email",
  cron: {
    pattern: "0 * * * *",
    window: "0m", // 计划允许时严格整点
  },
  ttl: "5m",
  run: async (payload) => {
    const hour = utcHourKey(payload.timestamp);
    const idempotencyKey = `trigger-dev-cron-hourly-ops:${hour}`;
    // build + POST /emails
    return { idempotencyKey };
  },
});

命令式 / 多租户日程

定义没有声明式 cron 的定时任务(或声明式 + 多个命令式附件),再从你的后端为每个用户创建日程:

// trigger/todo-reminder.ts
import { schedules } from "@trigger.dev/sdk";

export const todoReminder = schedules.task({
  id: "todo-reminder",
  run: async (payload) => {
    if (!payload.externalId) {
      throw new Error("externalId is required for per-user reminders");
    }
    const userId = payload.externalId;
    const day = utcDayKey(payload.timestamp);
    const idempotencyKey = `trigger-dev-cron-todo-reminder:${userId}:${day}`;

    // const user = await db.getUser(userId);
    // await sendTransactional({ to: [user.email], …, idempotencyKey });

    return { userId, idempotencyKey, scheduleId: payload.scheduleId };
  },
});
// app/api/reminders/route.ts(你的后端 — 不是 Trigger.dev run)
import { schedules } from "@trigger.dev/sdk";
import { todoReminder } from "../../trigger/todo-reminder";

export async function POST(request: Request) {
  const data = await request.json();

  const created = await schedules.create({
    task: todoReminder.id,
    cron: "0 8 * * *",
    timezone: data.timezone ?? "UTC",
    externalId: data.userId,
    // 必填 — upsert,避免重复创建
    deduplicationKey: `${data.userId}-todo-reminder`,
    // 可选:许多用户共享 08:00 时打散
    window: "30m",
  });

  return Response.json(created);
}

本地用 npx trigger.dev@latest dev、生产用 deploy 同步声明式日程。命令式日程可用 schedules.retrieve / update / deactivate / activate / del 管理(SDK management)。

窗口级 Idempotency-Key

Rovela 的 Idempotency-Key(最长 256 字符)让同一请求在 24 小时内重试时返回原始响应,而不是再发一封(发送邮件 API)。对 Trigger.dev 尤其重要,因为:

  • 任务 重试 会在 5xx / 超时后重新进入 run
  • 控制台 Test schedule 调试时可能对同一日历窗口再开火(测试 scheduleId 可能是占位符如 sched_1234 —— 仍要用窗口键)
  • 日程 window 会在名义 cron 之后偏移实际启动;请按 payload.timestamp(名义)或业务日做键,而不是 Date.now() 毫秒
  • 命令式多租户日程可能共享同一 cron —— 把 externalId 写进键

推荐键:

键适用场景
trigger-dev-cron-daily-digest:{yyyy-mm-dd}每个 UTC 日一封运维 digest
trigger-dev-cron-hourly-ops:{yyyy-mm-dd}T{HH}每小时快照(0 * * * *)
trigger-dev-cron-tokyo-ops:{yyyy-mm-dd}本地时区早报(按日程时区日)
trigger-dev-cron-weekly-report:{yyyy}-W{ww}每个 ISO 周一封创始人报告
trigger-dev-cron-todo-reminder:{userId}:{yyyy-mm-dd}按用户的命令式日程

若一次运行要发很多个性化 digest,优先每收件人一个键(或对最多 100 封非模板消息使用 POST /emails/batch —— batch 会拒绝 template / topic_id(发送邮件 API))。

挂载密钥并验证日程

  1. 在 Trigger.dev 的 Development / Staging / Production 环境设置 ROVELA_API_KEY(sending_access)、MAIL_FROM、DIGEST_TO。
  2. 跑 dev(声明式同步 + 本地唤醒)或 deploy(生产同步;只有当前部署的任务会开火)。
  3. 在 Tasks 页打开定时任务 → Schedules 标签;确认 cron、时区、window 与下次运行。
  4. 等待下一次 tick,或使用 Test schedule,同时保持窗口键稳定。
  5. 在运行日志中查看 idempotencyKey + Rovela emailId。可选再用 Rovela webhook 关联投递事件。

生产发信键优先 sending_access —— 不要用 full_access(鉴权)。绝不打印 API 密钥。Rovela 轮换用先创建再吊销;Trigger.dev 密钥按控制台指引轮换。

故障模式与处理

现象可能原因处理
开发环境 cron 从不跑Dev CLI 未运行启动 npx trigger.dev@latest dev(wake rules)
生产 cron 从不跑任务不在当前部署 / 日程未激活重新 deploy;检查 Schedules 标签;激活命令式日程
没有 schedule忘了声明式 cron 也没创建命令式加上 cron 或 schedules.create
Rovela 401ROVELA_API_KEY 错误 / 缺失修正 Trigger.dev 环境;确认 sending_access
Rovela 400 提到域名MAIL_FROM 域名未验证在 Rovela Domains 验证域名
Rovela 429限流throw 让 Trigger.dev 重试;保持同一 Idempotency-Key
重复 digest重试 / Test schedule 且无稳定键使用窗口级键;Idempotency-Key 在 24h 内合并重复
创建日程被拒(免费)cron 高于每小时 / window 过小放慢 cron 或升级;见免费计划窗口策略
过时 digest 堆积长运行与下一 tick 重叠给定时任务设 ttl
密钥进 Git / 客户端密钥提交或打进包只放在 Trigger.dev 环境 / vault

生产检查清单

  • Rovela 发信域名已验证;MAIL_FROM 匹配
  • ROVELA_API_KEY 为 sending_access,仅在 Trigger.dev 服务端环境
  • schedules.task 的 cron 已文档化(含时区与 window,若使用)
  • 声明式已通过 deploy 同步,和/或命令式 deduplicationKey 已文档化
  • Rovela 发送使用由 payload.timestamp(多租户时加 externalId)导出的窗口级 Idempotency-Key
  • 高频日程可选 ttl;理解免费计划频率限制
  • 日志显示成功 emailId,且不打印密钥
  • 连续任务失败有告警
  • 可选:对 digest 收件人配置 Rovela email.bounced / email.complained webhook

FAQ

Trigger.dev 能否不经过我的任务直接调 Rovela?实际上不行 —— 日程必须跑你的 run,才能聚合实时数据并在本地持有 sending_access。Rovela 不托管 Trigger.dev 日程。

要不要在任务里用 Rovela scheduled_at?通常不要。若 Trigger.dev 已在发送时刻带着实时正文触发,立刻 POST /emails 即可。当你现在受理消息、希望 Rovela 稍后再投递且不必再唤醒时,才用 scheduled_at(定时发送邮件)。

Trigger.dev vs Inngest cron vs QStash vs 平台 cron?日程应落在 Trigger.dev 后台任务旁(声明式 + 命令式 / 多租户)时用 Trigger.dev。要 Inngest 持久步骤 / fan-out 时用 Inngest(Inngest Cron)。要 HTTP 原生签名日程时用 QStash(QStash)。日程应落在某平台算力旁时用 Vercel/Render/K8s 等专文。

声明式还是命令式?固定内部作业优先声明式(Git 版本控制,deploy 同步)。按用户 / 动态日程优先命令式(externalId + deduplicationKey)。

一次性延迟任务呢?用 Trigger.dev 的 delay / 未来触发做单次未来运行 —— 不是本篇循环 cron 指南(Scheduled tasks note)。

sending_access 能做什么?足以通过 POST /emails / batch 入队发信。读消息、改期、取消需要 full_access 或控制台会话(发送邮件 API、鉴权)。定时任务请保持 sending_access。

200 是否表示创始人已收到 digest?否 —— 只表示 Rovela 已接受并入队。用 GET /emails/:id、事件或 webhook 看投递状态(快速开始)。

为什么既要 Trigger.dev 重试又要 Rovela Idempotency-Key?Trigger.dev 在你的代码失败时重试任务;若同一窗口内 HTTP 成功了两次(或超时后 Rovela 已接受),Rovela 的键会合并重复发送。两层都要。

把日程发出去,让 Rovela 投递

用声明式和/或命令式 cron 声明 schedules.task,在 run 里做实时聚合与 POST /emails,把 Idempotency-Key 限定到调度窗口(以及租户),并保持 sending_access 仅服务端。Trigger.dev 按时唤醒;Rovela 投递事务邮件。