Agenda.js / node-agenda 定时任务事务邮件 + Rovela(2026):define + every 与按窗口幂等键
用 Agenda.js(MongoDB 后端的 Agenda 6.x define + every/schedule)调度,由任务处理器经 Rovela POST /emails 发送事务摘要:sending_access 仅服务端,并按日程窗口设置 Idempotency-Key。
Agenda.js 负责 MongoDB 定时任务 — Rovela 负责投递邮件
Agenda(常被搜作 Agenda.js / node-agenda)是面向 Node.js 的轻量任务调度库。Agenda 6.x 需要单独安装存储后端——常见是 @agendajs/mongo-backend(MongoDB)——然后用 define 注册处理器,再用 every、schedule 或 now 安排执行。周期性任务会落成一等的任务文档(every 生成 single 类型),因此 Web 进程重启后再次调用 every 也不会复制出多行 cron(Agenda 文档、npm agenda)。
这种形态适合摘要、运维报告、试用到期提醒等事务邮件:正文依赖触发时刻的实时应用 / 数据库状态(注册数、未付发票、失败任务),而不是可以提前几小时用 Rovela scheduled_at 冻住的内容。
当你的 Agenda 任务处理器调用 POST https://api.rovela.dev/emails 时,Rovela 负责入队、DKIM 签名与投递(发送邮件 API、快速开始、鉴权)。Agenda(加上 MongoDB,或其他 Agenda 后端)负责日程、锁、并发与重试。你的处理器负责聚合、保管 sending_access 密钥,以及按日程窗口稳定的 Idempotency-Key。Rovela 只负责投递。
本指南串联 Agenda define + every / schedule → 任务处理器 → Rovela,实现:
- 用
agenda.define(name, handler, options?)定义任务(Agenda v6 参数顺序:先 handler,再 options——见 v5→v6 迁移指南) - 将
ROVELA_API_KEY(sending_access)、MAIL_FROM、Mongo URL 与收件人放在仅服务端环境——绝不放进浏览器、公开 Git,或会被广泛打印的 job payload - 在处理器内按当前日程窗口聚合摘要 / 报告
- 经 Rovela 发送,并带上按窗口作用域的
Idempotency-Key - 在 Agenda 锁重试、进程重启与重复触发下仍不重复发信
Rovela 不会创建 Agenda 集合、Mongo 索引或 cron 行。 Agenda 负责 schedule → lock → handler。你的处理器负责查询 → 映射字段 → POST /emails。Rovela 负责投递。
与下列文章区分:
- Temporal Schedule 邮件 — Temporal Schedule Spec → Workflow → Activity(不是 MongoDB Agenda)
- BullMQ 可重复 / Cron 任务邮件 — Redis BullMQ
upsertJobScheduler(不是 Agenda) - Trigger.dev Cron / 定时任务邮件 — Trigger.dev
schedules.task - Inngest Cron 事务邮件 — Inngest
cron+step.run - Upstash QStash Schedules 邮件 — QStash HTTPS 日程 +
Upstash-Signature - Supabase pg_cron 邮件 — Postgres
pg_cron+pg_net - Kubernetes CronJob 邮件 —
batch/v1CronJob → Job Pod - DigitalOcean App Platform Jobs 邮件 — App Platform
kind: SCHEDULED - Heroku Scheduler 邮件 — 粗粒度 Scheduler dyno
- Cloudflare Workers Cron Triggers 邮件 — Workers
triggers.crons - Azure Functions Timer 邮件 — Azure NCRONTAB
- GCP Cloud Scheduler 邮件 — Cloud Scheduler → Cloud Functions / Pub/Sub
- AWS EventBridge Scheduler 邮件 — EventBridge Scheduler → Lambda
- Netlify Scheduled Functions 邮件 — Netlify UTC cron
- Fly Scheduled Machines 邮件 — Fly Machine schedules
- Render Cron 事务邮件 — Render Cron Job 进程
- Railway Cron 事务邮件 — Railway cron 服务
- Vercel Cron 摘要邮件 — 平台 cron
GET到生产路由 - GitHub Actions 事务邮件 — workflow / CI 通知
- 定时发送邮件 — 正文已知时用 Rovela
scheduled_at
也不同于没有周期性 every 的一次性 Agenda schedule / now——本文聚焦 Mongo 支撑的周期性 cron / 间隔路径(并对一次性 schedule 做简短说明)。
Agenda 调度什么,Rovela 发送什么
| 关注点 | 责任方 |
|---|---|
任务名、every 间隔 / cron、timezone、skipImmediate、startDate / endDate / skipDays | Agenda every |
| 任务文档持久化与加锁 | 你的 MongoDB(或其他 Agenda 后端) |
轮询(processEvery)、并发、lockLifetime | Agenda 实例 + define 选项 |
ROVELA_API_KEY(sending_access)服务端密钥 | Agenda worker 进程环境 / 密钥库 |
| 按窗口查询 DB / API | 你的 define 处理器 |
| 渲染 HTML + text | 你的处理器 |
POST /emails、DKIM、入队、退信 | Rovela |
Idempotency-Key = agenda-cron-daily-digest:{yyyy-mm-dd}(或小时 / 租户桶) | 你的处理器 |
Rovela 返回 200 表示已接受并入队,并不等于「已进收件箱」(快速开始)。
影响发信 worker 的 Agenda 约束
根据 Agenda 公开文档(v6.x),把这些当作发信日程的硬要求:
- 安装核心 + 后端。
npm install agenda @agendajs/mongo-backend(Node.js 18+,MongoDB 4+)。v6 核心包不再内置 MongoDB(迁移指南、mongo-backend README)。 - 用
backend: new MongoBackend(...)构造。 传address或已有mongoDb;可选collection(默认agendaJobs)、ensureIndex、sort(Agenda 文档)。 define(name, fn, options?)— v6 中 handler 在 options 之前。 选项含concurrency、lockLimit、lockLifetime、priority、backoff、removeOnComplete(define)。every(interval, name, data?, options?)创建single任务。 每次启动再跑同一行every仍只有一条周期任务——专为经常重启的文件设计(every)。- 间隔形式。 人类可读(
'15 minutes'、'1 hour')、cron 字符串('0 * * * *')或Number(毫秒)(every)。 every的时区与边界。 需要timezone、skipImmediate、forkMode、startDate、endDate、skipDays(0=周日 … 6=周六)时,必须同时传data(every)。- 一次性用
schedule(when, name, data?)。 人类可读('tomorrow at noon'、'in 5 minutes')或Date(schedule)。 - 先
start();关停用drain()/stop()。 有关停时限的平台优先drain(timeoutMs),让进行中的 Rovela 调用有机会结束(Stopping / Draining)。 sending_access仅服务端。 把ROVELA_API_KEY放在 Agenda 进程环境——不要放进运维会浏览的 Mongo jobdata、客户端包或 Git(鉴权)。- 优先 HTTPS
POST /emails。 从 job worker 直接出站 SMTP 通常不合适(Email API vs SMTP)。
何时用 Agenda + POST /emails(而不是 scheduled_at)
在这些情况下用 Agenda every / cron → 实时发送:
- HTML 必须包含触发时的新鲜指标
- 你已有 MongoDB(或其他 Agenda 后端),希望周期任务与同一 Node 应用放在一起
- 需要自托管、可持久的 cron,又不想上 Redis BullMQ、Temporal 或 SaaS 调度器
- 向运维 / 创始人 / 运行时查到的客户地址发送一封(或少量)事务邮件
当正文已经正确、只需要「稍后再投」且不必常驻 Agenda worker 时,用 Rovela scheduled_at(定时发送邮件)。Agenda 日程与 scheduled_at 解决不同问题;本文是 Agenda 路径。
若你更想用 BullMQ / Temporal / Trigger.dev / Inngest / QStash,见上文链接的对应文章。若日程应紧挨某云平台算力,则用该平台的 cron 文。
前置条件
- Rovela 中已验证的发信域名。未验证的
from会以400拒绝(快速开始)。 - 形如
re_…的 API 密钥。Agenda worker 优先sending_access(鉴权)。 - Agenda 进程可访问的 MongoDB,并已安装
agenda与@agendajs/mongo-backend。 - 进程环境:
ROVELA_API_KEY、MAIL_FROM、DIGEST_TO、MONGODB_URI(以及处理器需要的其他 DB URL)。 - 至少一个长期运行并调用
agenda.start()的进程——只define而不启动 Agenda 不会发信。
define + every + Rovela(TypeScript,Agenda 6.x)
每天 09:00 UTC 的摘要,使用 cron 字符串与 timezone: 'UTC':
// src/jobs/digest-agenda.ts
import { Agenda } from "agenda";
import { MongoBackend } from "@agendajs/mongo-backend";
const ROVELA_URL = "https://api.rovela.dev/emails";
const JOB_NAME = "daily-digest-email";
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
}
async function buildDailyDigest(day: string) {
// 换成你对 Mongo / Postgres / 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 async function startDigestAgenda() {
const agenda = new Agenda({
backend: new MongoBackend({
address: requireEnv("MONGODB_URI"),
collection: "agendaJobs",
}),
processEvery: "30 seconds",
maxConcurrency: 20,
});
// v6: define(name, handler, options?) — options 在后
agenda.define(
JOB_NAME,
async (job) => {
const when =
(job.attrs.lastRunAt as Date | undefined) ??
(job.attrs.nextRunAt as Date | undefined) ??
new Date();
const day = utcDayKey(when instanceof Date ? when : new Date(when));
const idempotencyKey = `agenda-cron-daily-digest:${day}`;
const digest = await buildDailyDigest(day);
const to = requireEnv("DIGEST_TO")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
// 切勿打印 ROVELA_API_KEY 或 Mongo 密码
console.log(
JSON.stringify({
day,
idempotencyKey,
jobName: job.attrs.name,
jobId: String(job.attrs._id ?? ""),
kind: job.attrs.data?.kind,
})
);
const result = await sendTransactional({
to,
subject: digest.subject,
html: digest.html,
text: digest.text,
idempotencyKey,
tags: {
kind: "agenda-cron-daily-digest",
day,
platform: "agenda",
jobName: JOB_NAME,
},
});
return { ok: true, emailId: result.id, day };
},
{
concurrency: 1, // 每个实例同一时间只发一封摘要
lockLifetime: 10 * 60 * 1000,
priority: "normal",
}
);
await agenda.start();
// every 创建单条周期任务 — 每次启动调用都安全
await agenda.every(
"0 9 * * *", // 09:00 UTC(cron)
JOB_NAME,
{ kind: "daily-digest" },
{ timezone: "UTC", skipImmediate: true }
);
agenda.on("fail", (err, job) => {
console.error(
JSON.stringify({
event: "agenda_job_failed",
jobName: job?.attrs?.name,
message: err.message,
})
);
});
const shutdown = async () => {
// 等待进行中的 Rovela POST(云平台常见约 30s)
await agenda.drain(30_000);
process.exit(0);
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
return agenda;
}
在长期运行的专用 worker(或你有意把 Agenda 与 API 同进程部署时的 API 进程)中调用 startDigestAgenda()。
本地时区早晨摘要:
await agenda.every(
"0 5 * * *", // 该时区 05:00
"tokyo-morning-ops-email",
{ kind: "tokyo-ops" },
{ timezone: "Asia/Tokyo", skipImmediate: true }
);
// 在处理器中按日程时区的「日」构造幂等键:
// const day = new Date().toLocaleDateString("en-CA", { timeZone: "Asia/Tokyo" });
// const idempotencyKey = `agenda-cron-tokyo-ops:${day}`;
人类可读间隔(every 同样支持):
await agenda.every("1 hour", "hourly-ops-email", { kind: "hourly-ops" }, {
skipImmediate: true,
});
// 小时级幂等键示例:
// const hour = new Date().toISOString().slice(0, 13); // YYYY-MM-DDTHH
// const idempotencyKey = `agenda-cron-hourly-ops:${hour}`;
用 schedule 做一次性延迟事务发送(不是周期 cron):
await agenda.schedule("in 15 minutes", "trial-expiry-nudge", {
userId: "u_123",
kind: "trial-nudge",
});
// 处理器应使用类似 `agenda-schedule-trial-nudge:u_123:2026-11-05` 的 Idempotency-Key,
// 避免 Agenda 重试 / 锁重跑导致双发。
按窗口的 Idempotency-Key(必需)
Agenda 可能在崩溃、锁过期或 handler 抛错后重跑任务。Rovela 的 Idempotency-Key(最长 256 字符,24 小时重放窗口)会返回原响应而不是再发一封(发送邮件 API)。
| 节奏 | 示例键 |
|---|---|
| 日摘要 | agenda-cron-daily-digest:2026-11-05 |
| 小时运维 | agenda-cron-hourly-ops:2026-11-05T09 |
| 按租户日摘要 | agenda-cron-tenant-digest:tenant_42:2026-11-05 |
| 一次性 schedule | agenda-schedule-trial-nudge:u_123:2026-11-05 |
不要每次尝试用随机 UUID——那会毁掉幂等。不要把 API 密钥写进键或日志。
密钥与运维清单
- Agenda 进程环境 / 密钥管理中仅使用
sending_access的ROVELA_API_KEY(鉴权) MAIL_FROM落在已验证域名MONGODB_URI对 Agenda 集合最小权限- 收件人来自环境或服务端查询——不要硬编码进公开仓库
- SIGTERM 时优雅
drain(),让进行中的POST /emails有机会完成 - 监控 Agenda
fail事件,以及 Rovela webhook /GET /emails/:id投递状态(快速开始)
排障
| 现象 | 可能原因 |
|---|---|
| 任务从不跑 | 忘了 await agenda.start(),或没有进程让 Agenda 常驻 |
| 升级后出现重复 cron 行 | 混用 v5 的 db / mongo 构造与 v6 的 backend——按迁移指南改 |
every 的 options 被忽略 | 使用 options 参数时必须同时传 data(every) |
Rovela 401 | Bearer 密钥缺失/无效,或密钥类型不匹配该端点 |
Rovela 对 from 返回 400 | 域名未验证(快速开始) |
| 重试时双发 | 缺少或不稳定的按窗口 Idempotency-Key |
| 负载下 handler 超时 | 提高 lockLifetime、降低 concurrency,或把重聚合移出关键路径 |
小结
Agenda.js 用 define、every 与 schedule,在 MongoDB(或 v6 的 Postgres / Redis 后端)上调度周期与一次性工作。你的任务处理器聚合实时状态,并用仅服务端的 sending_access 密钥与按窗口的 Idempotency-Key 调用 Rovela POST /emails。Rovela 负责投递;Agenda 不会变成 ESP,Rovela 也不会变成你的 cron。
下一步:验证域名,创建 sending_access 密钥,在预发环境跑上述 worker,再用返回的邮件 id 与事件 / webhook 确认投递。