Trigger.dev 定时任务事务邮件 + Rovela(2026):schedules.task 与按窗口的幂等键
用 Trigger.dev schedules.task(声明式 cron 或命令式 schedules.create)对接 Rovela POST /emails:sending_access 仅服务端,按调度窗口设置 Idempotency-Key,适合 digest 与多租户提醒。
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,实现:
- 用声明式
cron(UTC 字符串或{ pattern, timezone, environments, window })声明schedules.task(),和/或用schedules.create({ task, cron, deduplicationKey, … })挂命令式日程 - 把
ROVELA_API_KEY(sending_access)、MAIL_FROM、收件人放在仅服务端密钥中 —— Trigger.dev 环境变量 / vault,绝不进浏览器或公开 Git 树 - 在任务
run内按当前调度窗口聚合 digest / 报告 - 用窗口级
Idempotency-Key调用 Rovela 发送 - 在 Trigger.dev 重试、重叠 tick、控制台「Test schedule」下仍不重复发信
Rovela 不会创建 Trigger.dev 项目、日程或 API 密钥。 Trigger.dev 拥有 日程 → 调用 → 可靠任务运行;你的任务拥有 查询 → 映射字段 → POST /emails;Rovela 拥有投递。
与以下文章角度不同:
- Inngest Cron 事务邮件 — Inngest
cron触发器 +step.run(不是 Trigger.devschedules.task) - Upstash QStash Schedules 邮件 — QStash HTTPS 日程 +
Upstash-Signature - Supabase pg_cron 邮件 — Postgres
pg_cron+pg_net→ Edge Function - Kubernetes CronJob 邮件 —
batch/v1CronJob → Job Pod - DigitalOcean App Platform Jobs 邮件 — App Platform
kind: SCHEDULEDJob - Heroku Scheduler 邮件 — 粗粒度 Scheduler dyno
- Cloudflare Workers Cron Triggers 邮件 — Workers
triggers.crons+scheduled() - Azure Functions Timer 邮件 — Azure NCRONTAB
- GCP Cloud Scheduler 邮件 — Cloud Scheduler HTTP/OIDC 或 Pub/Sub
- AWS EventBridge Scheduler 邮件 — EventBridge Scheduler → Lambda
- Netlify Scheduled Functions 邮件 — Netlify UTC cron
- Fly Scheduled Machines 邮件 — Fly Machine 日程
- Render Cron 事务邮件 — Render Cron Job 进程
- Railway Cron 事务邮件 — Railway cron 服务
- Vercel Cron Digest 邮件 — 平台 cron
GET生产路由 - GitHub Actions 事务邮件 — workflow / CI 通知
- 定时发送邮件 — 正文已知时用 Rovela
scheduled_at
也不同于 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 专文。
前置条件
- Rovela 上已验证发信域名。未验证的
from域名会返回400(快速开始)。 - 形如
re_…的 API 密钥。定时任务优先sending_access(鉴权)。 - Trigger.dev 项目,安装
@trigger.dev/sdk、CLI(npx trigger.dev@latest),任务放在配置的 trigger 目录(常见为/trigger)(Scheduled tasks)。 - Trigger.dev 环境变量:
ROVELA_API_KEY、MAIL_FROM、DIGEST_TO(以及任务需要的 DB URL)。 - 生产环境:成功 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))。
挂载密钥并验证日程
- 在 Trigger.dev 的 Development / Staging / Production 环境设置
ROVELA_API_KEY(sending_access)、MAIL_FROM、DIGEST_TO。 - 跑
dev(声明式同步 + 本地唤醒)或deploy(生产同步;只有当前部署的任务会开火)。 - 在 Tasks 页打开定时任务 → Schedules 标签;确认 cron、时区、window 与下次运行。
- 等待下一次 tick,或使用 Test schedule,同时保持窗口键稳定。
- 在运行日志中查看
idempotencyKey+ RovelaemailId。可选再用 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 401 | ROVELA_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.complainedwebhook
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 投递事务邮件。