← 返回博客

Upstash QStash Schedules 事务邮件 + Rovela(2026):签名校验与按窗口的幂等键

把 Upstash QStash 按 cron 用 HTTPS 唤醒你端点的日程接到 Rovela POST /emails:校验 Upstash-Signature、sending_access 仅服务端存放,并用按窗口的 Idempotency-Key,让 QStash 重试不产生重复摘要。

更新于 2026年11月5日
Upstash QStash Schedules 事务邮件 + Rovela(2026):签名校验与按窗口的幂等键

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,做到:

  1. 用 Upstash-Cron 创建日程(默认 UTC,或 CRON_TZ=… 指定 IANA 时区),可选稳定的 Upstash-Schedule-Id
  2. 用 QStash Upstash-Signature JWT(Receiver / verifySignatureAppRouter)与 current + next 签名密钥校验每一次入站请求
  3. 将 ROVELA_API_KEY(sending_access)、MAIL_FROM、收件人放在 仅服务端 密钥中——绝不进浏览器或公开仓库
  4. 为当前调度窗口聚合摘要/报告
  5. 经 Rovela 发送,并使用按窗口的 Idempotency-Key
  6. 在 QStash 重试、重叠触发与手动再投递时不产生重复邮件

Rovela 不会创建 QStash schedule、签名密钥或 URL Group。 QStash 负责 schedule → HTTPS POST → 重试。你的端点负责校验 → 查询 → 映射字段 → POST /emails。Rovela 负责投递。

与下列文章区分:

也不同于一次性 QStash publish(延迟一次)或长期队列消费者——本文只讲 周期性 schedule 路径。

QStash Schedules 做什么,Rovela 发什么

关注点负责方
Cron 表达式(Upstash-Cron)、可选 CRON_TZ=IANA、schedule idQStash 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-Signature JWT(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 / …)——见上文链接的对应文章。

前置条件

  1. Rovela 上 已验证的发信域名。未验证的 from 域会返回 400(Quick Start)。
  2. 形如 re_… 的 API 密钥。日程处理器优先 sending_access(Authentication)。
  3. 已启用 QStash 的 Upstash 账号、QStash token,以及控制台中的 current + next 签名密钥。
  4. 可公开访问的 HTTPS 端点(Next.js App Router、Cloudflare/Fly 上的 Hono 等),能校验签名并调用 Rovela。
  5. 服务端环境变量: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 退避并用 同一 键重试。

配置密钥并验证日程

  1. 部署已校验的端点;确认 QSTASH_* 签名密钥与 ROVELA_API_KEY(sending_access)仅在服务端。
  2. 用稳定 scheduleId 创建(或覆盖)日程。
  3. 等待首次触发(创建后最多约 60 秒),或在保持窗口键稳定的前提下向同一目标发一次测试消息。
  4. 在应用日志中查看 idempotencyKey 与 Rovela emailId。之后可用 Rovela webhook 关联。

生产发信优先 sending_access——不要用 full_access(Authentication)。切勿打印 API 密钥或签名密钥。Rovela 轮换用先创建再吊销;QStash 签名密钥按控制台指引轮换(current + next 存在是为了轮换时不打断进行中的校验)。

故障与处理

现象可能原因处理
到达 Rovela 前就 401缺少/无效 Upstash-Signature确认 current + next;用原始 body;sub 与目标 URL 一致(signature)
Rovela 401ROVELA_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.complained webhook
  • 可选:重试耗尽时的 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,并在日志中确认首次触发。