Upstash QStash Schedules 事务邮件 + Rovela(2026):签名校验与按窗口的幂等键
把 Upstash QStash 按 cron 用 HTTPS 唤醒你端点的日程接到 Rovela POST /emails:校验 Upstash-Signature、sending_access 仅服务端存放,并用按窗口的 Idempotency-Key,让 QStash 重试不产生重复摘要。
Upstash QStash Schedules 负责定时 — Rovela 负责投递
Upstash QStash 是无无服务器 HTTP 消息队列与调度器。除一次性 publish 外,你还可以创建 schedule:向 https://qstash.upstash.io/v2/schedules/{destination}(或区域主机 qstash-{region}.upstash.io)POST,并带上 Upstash-Cron 头,QStash 就会按 cron 节奏反复投递同一条消息。每一次触发都是对目标 URL(或 URL Group)的 HTTPS 调用,目标出错时还有自动重试。
这种形态适合摘要、运维报告、试用到期提醒,以及其他正文取决于 触发时的实时应用/数据库状态 的事务邮件——注册量、未付发票、失败任务——而不是能提前几小时用 Rovela scheduled_at 冻住的内容。
当你的目标端点调用 POST https://api.rovela.dev/emails 时,Rovela 负责入队、DKIM 签名与投递(Send Email API、Quick Start、Authentication)。QStash 负责日程、HTTP 投递与重试。你的应用负责 签名校验、聚合、sending_access 密钥,以及按调度窗口稳定的 Idempotency-Key。Rovela 只负责投递。
本指南串联 QStash Schedules → 你的 HTTPS 端点 → Rovela,做到:
- 用
Upstash-Cron创建日程(默认 UTC,或CRON_TZ=…指定 IANA 时区),可选稳定的Upstash-Schedule-Id - 用 QStash
Upstash-SignatureJWT(Receiver/verifySignatureAppRouter)与 current + next 签名密钥校验每一次入站请求 - 将
ROVELA_API_KEY(sending_access)、MAIL_FROM、收件人放在 仅服务端 密钥中——绝不进浏览器或公开仓库 - 为当前调度窗口聚合摘要/报告
- 经 Rovela 发送,并使用按窗口的
Idempotency-Key - 在 QStash 重试、重叠触发与手动再投递时不产生重复邮件
Rovela 不会创建 QStash schedule、签名密钥或 URL Group。 QStash 负责 schedule → HTTPS POST → 重试。你的端点负责校验 → 查询 → 映射字段 → POST /emails。Rovela 负责投递。
与下列文章区分:
- Supabase pg_cron 邮件 — Postgres
pg_cron+pg_net→ Edge Function(不是 QStash HTTP 日程) - 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+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 摘要邮件 — 平台 cron
GET生产路由 - GitHub Actions 事务邮件 — workflow / CI 通知
- 定时发送邮件 — 正文已知时用 Rovela
scheduled_at
也不同于一次性 QStash publish(延迟一次)或长期队列消费者——本文只讲 周期性 schedule 路径。
QStash Schedules 做什么,Rovela 发什么
| 关注点 | 负责方 |
|---|---|
Cron 表达式(Upstash-Cron)、可选 CRON_TZ=IANA、schedule id | QStash Schedules(schedules、Create a Schedule) |
| 默认 HTTPS POST 到目标 URL / URL Group;重试 / 超时 | QStash 投递 |
用 current + next 签名密钥校验 Upstash-Signature(JWT) | 你的端点(Verify Signatures) |
ROVELA_API_KEY(sending_access)服务端密钥 | 你的主机 / vault / 平台环境变量 |
| 查询本窗口的 DB / API | 你的端点 |
| 渲染 HTML + 纯文本 | 你的端点 |
POST /emails、DKIM、队列、退信 | Rovela |
Idempotency-Key = qstash-cron-daily-digest:{yyyy-mm-dd}(或小时/分钟桶) | 你的端点 |
Rovela 返回 200 表示 已接受并入队,不等于「已进收件箱」(Quick Start)。
塑造邮件端点的 QStash 日程约束
根据 Upstash 文档,发邮件的 schedule 应把这些当作硬要求:
- 用
Upstash-Cron创建。 向/v2/schedules/{destination}POST 并带 cron 头(或 TS SDK 的client.schedules.create({ destination, cron }))。目标须为http:///https://(或 URL Group 名)(schedules)。 - 最高分辨率 1 分钟。 最细为每分钟一次——不是亚分钟(Create a Schedule)。
- 默认时区 UTC。 需要本地墙钟时在 cron 前加
CRON_TZ=America/New_York(任意 IANA 区)(Timezones)。 - 首次触发可能延迟约 60 秒,等待日程加载到活跃节点(schedules)。
- 稳定 schedule id。 传
Upstash-Schedule-Id/scheduleId以创建或覆盖同一日程,避免重复(Overwrite)。id 限字母数字 / 连字符 / 点 / 下划线。 - 校验每一次请求。 QStash 目标是公开 HTTPS URL。知道路径的人就能 POST,除非你用控制台的 current + next 签名密钥校验
Upstash-SignatureJWT(Verify Signatures、Next.js quickstart)。 - 校验要用原始 body。 先 JSON 解析再校验会破坏 body 哈希声明——把原始字符串交给
Receiver.verify(signature howto)。 - 重试是常态。 端点出错或超时时默认 3 次重试(总投递 = 1 + retries)。可用
Upstash-Retries/Upstash-Retry-Delay调整。经 Rovela 的发送必须通过Idempotency-Key幂等。 - 不要把 Rovela 密钥放进 QStash。 不要写在 schedule body 或会进日志的
Upstash-Forward-*头里。只把sending_access放在应用服务端环境,并在校验通过后的处理器里调用 Rovela(Authentication)。 - 优先 HTTPS
POST /emails。 无服务器处理器里出站 SMTP 体验很差(Email API vs SMTP)。
何时用 QStash Schedules + POST /emails(而不是 scheduled_at)
在以下情况用 QStash schedule → 实时发送:
- HTML 必须在触发时包含 最新 指标
- 你已使用 Upstash(Redis / QStash),并希望调度器 不绑定 某一家 PaaS cron
- 需要 HTTP 重试、暂停/恢复与控制台管理的日程,而不想养长期 worker
- 一两封事务邮件发往运行时查到的运维 / 创始人 / 客户地址
当正文已经正确、只需要「稍后再投」且不必再唤醒任务时,用 Rovela scheduled_at(定时发送邮件)。QStash schedules 与 scheduled_at 解决不同问题;本文是 QStash 路径。
若更希望调度跟某平台计算资源放一起,用该平台 cron(Vercel / Render / K8s / Supabase pg_cron / …)——见上文链接的对应文章。
前置条件
- Rovela 上 已验证的发信域名。未验证的
from域会返回400(Quick Start)。 - 形如
re_…的 API 密钥。日程处理器优先sending_access(Authentication)。 - 已启用 QStash 的 Upstash 账号、QStash token,以及控制台中的 current + next 签名密钥。
- 可公开访问的 HTTPS 端点(Next.js App Router、Cloudflare/Fly 上的 Hono 等),能校验签名并调用 Rovela。
- 服务端环境变量:
QSTASH_CURRENT_SIGNING_KEY、QSTASH_NEXT_SIGNING_KEY、ROVELA_API_KEY、MAIL_FROM、DIGEST_TO。
创建日程(cURL + TypeScript)
把 destination 换成生产 URL。建议显式 schedule id,以便重新部署时更新而不是重复创建:
curl -XPOST \
'https://qstash.upstash.io/v2/schedules/https://api.example.com/qstash/daily-digest' \
-H "Authorization: Bearer $QSTASH_TOKEN" \
-H "Content-Type: application/json" \
-H "Upstash-Cron: CRON_TZ=America/New_York 0 4 * * *" \
-H "Upstash-Schedule-Id: daily-digest" \
-H "Upstash-Retries: 3" \
-H "Upstash-Timeout: 30s" \
-d '{"kind":"daily"}'
TypeScript SDK 等价写法(schedules examples):
import { Client } from "@upstash/qstash";
const client = new Client({ token: process.env.QSTASH_TOKEN! });
await client.schedules.create({
destination: "https://api.example.com/qstash/daily-digest",
cron: "CRON_TZ=America/New_York 0 4 * * *",
scheduleId: "daily-digest",
retries: 3,
body: JSON.stringify({ kind: "daily" }),
headers: { "content-type": "application/json" },
});
每 15 分钟运维脉冲(UTC):
await client.schedules.create({
destination: "https://api.example.com/qstash/ops-pulse",
cron: "*/15 * * * *",
scheduleId: "ops-pulse",
body: JSON.stringify({ kind: "fifteen" }),
headers: { "content-type": "application/json" },
});
需要停信但不删代码时,可用 Schedules API 或控制台 pause / resume / delete(OpenAPI schedules)。
Next.js App Router:先校验签名,再经 Rovela 发送
安装 @upstash/qstash。从 QStash 控制台设置 QSTASH_CURRENT_SIGNING_KEY、QSTASH_NEXT_SIGNING_KEY,并加上 Rovela 密钥。Next.js 助手会自动加载签名密钥(Next.js quickstart):
// app/api/qstash/daily-digest/route.ts
import { verifySignatureAppRouter } from "@upstash/qstash/nextjs";
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
}
function windowKey(kind: string) {
if (kind === "hourly") return `qstash-cron-hourly-ops:${utcHourKey()}`;
if (kind === "fifteen") {
const d = new Date();
const bucket = Math.floor(d.getUTCMinutes() / 15) * 15;
const hh = String(d.getUTCHours()).padStart(2, "0");
const mm = String(bucket).padStart(2, "0");
return `qstash-cron-fifteen-ops:${utcDayKey()}T${hh}:${mm}`;
}
return `qstash-cron-daily-digest:${utcDayKey()}`;
}
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 };
}
async function handler(req: Request) {
const payload = (await req.json().catch(() => ({}))) as { kind?: string };
const kind = payload.kind || process.env.DIGEST_KIND || "daily";
const day = utcDayKey();
const digest = await buildDailyDigest(day);
const to = requireEnv("DIGEST_TO")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
const idempotencyKey = windowKey(kind);
// 切勿打印 ROVELA_API_KEY 或 QStash 签名密钥
console.log(JSON.stringify({ kind, day, idempotencyKey }));
const result = await sendTransactional({
to,
subject: digest.subject,
html: digest.html,
text: digest.text,
idempotencyKey,
tags: {
kind: `qstash-cron-${kind}-digest`,
day,
platform: "upstash-qstash",
},
});
return Response.json({ ok: true, emailId: result.id });
}
// 在处理器运行前校验 Upstash-Signature
export const POST = verifySignatureAppRouter(handler);
仅在希望 QStash 重试 时返回非 2xx。若 Rovela 已在同一 Idempotency-Key 下接受消息,后续重试仍拿到 Rovela 200 是安全的——成功接受后不要再抛错。
用 Receiver 手动校验(任意 Node 运行时)
不在 Next.js App Router 时,用原始 body 显式校验(Receiver):
import { Receiver } from "@upstash/qstash";
const receiver = new Receiver({
currentSigningKey: process.env.QSTASH_CURRENT_SIGNING_KEY!,
nextSigningKey: process.env.QSTASH_NEXT_SIGNING_KEY!,
});
export async function POST(req: Request) {
const signature = req.headers.get("upstash-signature");
if (!signature) {
return new Response("missing signature", { status: 401 });
}
const body = await req.text(); // 原始字符串 — 校验必需
try {
await receiver.verify({
signature,
body,
url: "https://api.example.com/qstash/daily-digest", // 你 schedule 的目标
});
} catch {
return new Response("invalid signature", { status: 401 });
}
const payload = JSON.parse(body || "{}") as { kind?: string };
// ... 同上:聚合 + Rovela POST /emails ...
return Response.json({ ok: true });
}
Python 可用 qstash.Receiver 同样方式(signature howto)。
按窗口的 Idempotency-Key
Rovela 的 Idempotency-Key(最长 256 字符)使 24 小时内同一发送的重试返回原始响应而不是再发一封(Send Email API)。对 QStash 特别重要,因为:
- 默认 重试 会在 5xx / 超时后再次 POST 你的端点
- 调试时可能 手动 publish 同一目标
- 慢处理器 + 重叠 cron 可能再次进入同一日历窗口
- 省略稳定 id 时 schedule 覆盖/意外重复 id 可能双触发
推荐键:
| 键 | 适用 |
|---|---|
qstash-cron-daily-digest:{yyyy-mm-dd} | 每个 UTC 日一封运维摘要 |
qstash-cron-hourly-ops:{yyyy-mm-dd}T{HH} | 每小时快照(0 * * * *) |
qstash-cron-fifteen-ops:{yyyy-mm-dd}T{HH}:{mm} | 每 15 分钟脉冲(mm 向下取整到 00/15/30/45) |
qstash-cron-weekly-report:{yyyy}-W{ww} | 每个 ISO 周一封创始人报告 |
qstash-cron-customer-digest:{userId}:{yyyy-mm-dd} | 按用户摘要(每个收件人窗口一把键) |
若发大量个性化摘要,按收件人循环并用新键(或对最多 100 条非模板消息用 POST /emails/batch——batch 故意拒绝 template / topic_id(Send Email API))。限制并发;遇到 429 退避并用 同一 键重试。
配置密钥并验证日程
- 部署已校验的端点;确认
QSTASH_*签名密钥与ROVELA_API_KEY(sending_access)仅在服务端。 - 用稳定
scheduleId创建(或覆盖)日程。 - 等待首次触发(创建后最多约 60 秒),或在保持窗口键稳定的前提下向同一目标发一次测试消息。
- 在应用日志中查看
idempotencyKey与 RovelaemailId。之后可用 Rovela webhook 关联。
生产发信优先 sending_access——不要用 full_access(Authentication)。切勿打印 API 密钥或签名密钥。Rovela 轮换用先创建再吊销;QStash 签名密钥按控制台指引轮换(current + next 存在是为了轮换时不打断进行中的校验)。
故障与处理
| 现象 | 可能原因 | 处理 |
|---|---|---|
到达 Rovela 前就 401 | 缺少/无效 Upstash-Signature | 确认 current + next;用原始 body;sub 与目标 URL 一致(signature) |
Rovela 401 | ROVELA_API_KEY 错误/缺失 | 修正服务端密钥;确认 sending_access |
Rovela 400 提到域名 | MAIL_FROM 域未验证 | 在 Rovela Domains 验证域名 |
Rovela 429 | 限流 | 退避并用同一 Idempotency-Key 重试 |
| 重复摘要 | 重试/双日程且无稳定键 | 用按窗口的键;24h 内 Idempotency-Key 合并重复 |
| 日程从不触发 | cron 错误、区域主机不对、目标 URL 笔误 | 查控制台日程;用 crontab.guru;最高 1 分钟分辨率 |
| JSON 解析后校验失败 | body 被重新序列化 | 先对 req.text() / 原始字节校验 |
| 签名密钥进了 Git | 密钥入库或经 QStash 头转发 | 只放平台密钥;切勿 Upstash-Forward- Rovela 密钥 |
生产检查清单
- Rovela 发信域已验证;
MAIL_FROM匹配 ROVELA_API_KEY为sending_access,仅服务端- QStash schedule 有显式
scheduleId与文档化的 cron(非 UTC 时用CRON_TZ) - 端点在任何副作用前校验
Upstash-Signature(current + next) Idempotency-Key按调度窗口(日 / 小时 / 15 分钟桶)- Rovela 接受后返回 2xx 以减少多余重试;即便 QStash 再试也安全
- 日志有成功
emailId且不打印密钥 - 可选:摘要收件人上的 Rovela
email.bounced/email.complainedwebhook - 可选:重试耗尽时的 QStash failure callback(运维告警——转发头里仍不要带 Rovela 密钥)
FAQ
能让 QStash 直接按日程调用 Rovela 吗?技术上可以把目标设为 https://api.rovela.dev/emails,但你必须在创建日程时转发 Authorization 并写死正文——内容被冻住,且发送密钥可能暴露在 QStash 消息存储中。更稳妥的是 你自己的 已校验端点:聚合实时数据,并把 sending_access 留在本地。
QStash 处理器里该不该用 Rovela scheduled_at?通常不必。若 QStash 已在发送时刻带着实时正文触发,直接 POST /emails。当你现在接收消息、希望 Rovela 稍后再投且不必再唤醒时,再用 scheduled_at(定时发送邮件)。
QStash Schedules vs 平台 cron vs Supabase pg_cron?跨主机要 HTTP 原生调度与重试时用 QStash。希望日程跟某平台计算放一起时用 Vercel/Render/K8s cron。希望日程跟 Postgres 放一起时用 Supabase pg_cron(Supabase pg_cron 邮件)。
sending_access 覆盖什么?足以经 POST /emails / batch 入队。读消息、改期、取消需要 full_access 或仪表盘会话(Send Email API、Authentication)。QStash 处理器请保持 sending_access。
200 表示创始人已经收到摘要了吗?否——只表示 Rovela 已接受并入队。投递状态请用 GET /emails/:id、events 或 webhook(Quick Start)。
为什么既要 QStash 重试又要 Rovela Idempotency-Key?QStash 在端点失败时重试 HTTP 调用。若处理器在同一窗口成功两次(或 Rovela 接受后超时),Rovela 的键会合并重复 发送。两层都要。
把日程交给 QStash,把投递交给 Rovela
Upstash QStash Schedules 提供带可选 CRON_TZ 的 HTTP cron,可唤醒任意公开 HTTPS 端点,并带重试与控制台暂停/恢复。你的处理器负责签名校验、实时摘要、仅服务端的 sending_access,以及按窗口的 Idempotency-Key。Rovela 负责 HTTPS 接受、DKIM 与投递。密钥不要写进 schedule body,每次请求都要校验,即可在不自建 SMTP 或邮件栈的情况下发出事务摘要。
下一步:验证域名,创建 sending_access 密钥,部署带签名校验的端点,用稳定 id 创建 QStash schedule,并在日志中确认首次触发。