Heroku Scheduler 事务邮件 + Rovela(2026):One-Off Dyno、Config Vars 与按窗口的幂等键
把 Heroku Scheduler(每 10 分钟 / 每小时 / 每天的 one-off dyno)接到 Rovela POST /emails:用 config vars 存放 sending_access、一次性退出命令、UTC 每日时刻,以及按窗口的 Idempotency-Key,避免偶发双触发重复发摘要。
Heroku Scheduler 负责触发节奏 —— Rovela 负责投递邮件
Heroku Scheduler 是一个免费 add-on,可按 每 10 分钟、每小时 或 每天 在你的应用上执行一条命令——类似传统 cron,但每次触发都会启动一只 one-off dyno(日志里形如 scheduler.X)。这种形态适合摘要、运维日报等正文依赖触发时刻实时状态的事务邮件——注册数、失败任务、未付发票——而不是可以提前几小时用 Rovela scheduled_at 冻住的内容。
当你的一次性命令调用 POST https://api.rovela.dev/emails 时,Rovela 负责入队、DKIM 签名与投递(Send Email API、Quick Start、Authentication)。Heroku 负责粗粒度调度、one-off dyno 生命周期与 config vars。你的脚本负责聚合,以及按调度窗口稳定的 Idempotency-Key。Rovela 只负责投递。
本指南把 Heroku Scheduler → Rovela 串起来,做到:
- 把
ROVELA_API_KEY、MAIL_FROM、收件人放进 config vars(绝不进 Git,也不写进 Scheduler 任务字符串) - 提供会在发送结束后退出的一次性
bin/脚本(或 rake / process type) - 按当前调度窗口聚合摘要 / 报告
- 经 Rovela 发送,并使用按窗口作用域的
Idempotency-Key - 在偶发双触发、手动
heroku run冒烟、以及区间抖动重叠时,仍不重复发信
Rovela 不会创建 Heroku 应用、Scheduler 任务或 config vars。 Heroku 负责 schedule → one-off dyno;你的命令负责查询 → 映射字段 → POST /emails;Rovela 负责投递。
与下列文章区分:
- Cloudflare Workers Cron Triggers Email — Workers
triggers.crons+scheduled()(UTC 五段 cron) - Azure Functions Timer Email — Azure NCRONTAB(秒在前)+ App Settings / Key Vault
- GCP Cloud Scheduler Email — 五段 unix-cron / groc + HTTP/OIDC 或 Pub/Sub
- AWS EventBridge Scheduler Email — 六段 AWS cron /
rate/at+ Lambda - Netlify Scheduled Functions Email — Netlify UTC cron + 短函数时限
- Fly Scheduled Machines Email — 粗粒度 Machine 间隔
- Render Cron Transactional Email — Render Cron Job 进程且必须退出
- Railway Cron Transactional Email — Railway cron 服务
- Vercel Cron Digest Email — 对生产路由的 HTTP
GET+CRON_SECRET - GitHub Actions Transactional Email — 工作流 /
run_id在 CI 事件上通知 - Scheduled Email Sending — 正文已知时用 Rovela
scheduled_at
也不同于 Heroku 上的 自定义 clock 进程(clock + worker 队列)——当定时任务关键时,Heroku 自己更推荐该模式,因为 Scheduler 免费且预期会跑、但不保证(Scheduler、Scheduled Jobs and Custom Clock Processes)。本文是轻量的 Scheduler add-on → 一次性命令 → Rovela 路径。
Scheduler 做什么,Rovela 发什么
| 关注点 | 归属 |
|---|---|
| 每 10 分钟 / 每小时 / 每天(每日下次运行按 UTC) | Heroku Scheduler(文档) |
执行命令的 one-off dyno(scheduler.X) | Heroku dyno 管理 |
ROVELA_API_KEY / MAIL_FROM / 收件人的 config vars | 应用配置(config vars) |
| 按窗口查询 DB / API | 你的脚本 |
| 渲染 HTML + text | 你的脚本 |
POST /emails、DKIM、入队、退信 | Rovela |
Idempotency-Key = heroku-{kind}:{yyyy-mm-dd}(或小时 / 周) | 你的脚本 |
Rovela 返回 200 表示已接受并入队,不是“已经进收件箱”(Quick Start)。
影响邮件任务的 Heroku 约束
根据 Heroku Dev Center,把下列当作发信 Scheduler 任务的硬要求:
- 只有粗粒度频率。 控制台提供 每 10 分钟、每小时、每天——不是任意五段 cron。若要“一天三次”或短于 10 分钟,请改用自定义 clock 进程。
- 每日下次运行按 UTC。 若要“美东 09:00”,在 Scheduler 面板选对应的 UTC 时刻(Scheduling jobs)。
- one-off dyno,计费类似
heroku run。 add-on 本身免费,但启动的 dyno 计入月度 dyno 小时,发票上显示为scheduler类型(Dyno hour costs)。 - 预期会跑、但不保证。 极少数情况下任务可能被跳过或跑两次。关键调度请用自定义 clock(Known issues)。按至少一次投递设计,并保留按窗口的 Rovela
Idempotency-Key。 - 命令必须结束并退出。 Scheduler 启动的 dyno 与
heroku run同类。保持任务短(查询 + 一次 HTTPS 发送),或把长活丢给 worker 后退出。Scheduler 启动的 dyno 大致不会超过其调度间隔运行;10 分钟任务约在 10 分钟后被终止,并存在短暂重叠的抖动(Long-running jobs)。 - 不要把密钥写进任务字符串。 在 Common Runtime 上,Heroku 会在审计日志里先展开 config var 再记录命令。不要在 Scheduler「Task」字段写
$ROVELA_API_KEY(或任何密钥)——在脚本内读环境变量(Defining tasks)。 - 先用
heroku run测试。 若heroku run bin/send-daily-digest能跑,Scheduler 就会用同类 one-off dyno 跑同一命令(Testing tasks)。 - 进程类型或路径。 可填裸命令(
bin/send-daily-digest),或 Procfile 中的 process type 加参数。 - Container Registry 注意。 若用 Container Registry 部署,任务必须能从
web镜像访问——Scheduler 不能指定非 web 镜像(Known issues)。 - 密钥只留在服务端。 生产发信优先 Rovela
sending_accessconfig var——不要打进浏览器包或公开仓库(Authentication)。 - 出站 SMTP 不合适。 优先 443 上的 HTTPS
POST /emails(Email API vs SMTP)。
何时用 Scheduler + POST /emails(而不是 scheduled_at)
适合 Scheduler → 即时发送 的情况:
- HTML 必须包含触发时刻的新鲜指标
- 应用已在 Heroku 上跑,希望 config vars 与日志同处一处
- 10 分钟 / 每小时 / 每天 的节奏已够用(不需要自定义 cron 表达式)
- 一两封事务邮件发给运维 / 创始人 / 运行时查到的客户地址
正文已经正确、只想“稍后再投递”、且不需要常驻任务时,用 Rovela scheduled_at(Scheduled Email Sending)。Scheduler 与 scheduled_at 解决不同问题;本文是 Heroku 路径。
漏一次会痛(账期截止、合规摘要)时,用 自定义 clock + worker。两边可以共用同一套 Rovela 发送辅助函数。
前置条件
- Rovela 中已验证发信域名。未验证的
from域名会以400拒绝(Quick Start)。 - 形如
re_…的 API key。Scheduler 任务优先sending_access(Authentication)。 - 能出站 HTTPS 的 Heroku 应用(任意官方 buildpack / 能
fetch或用 HTTP 客户端的语言)。 - 为
ROVELA_API_KEY、MAIL_FROM、DIGEST_TO(或在代码里查收件人)配置 config vars。 - 已在该应用上开通 Scheduler add-on。
- 成功接受后以
0退出的一次性命令(或窗口无内容时主动 no-op)。
安装 Scheduler 并设置 config vars
# 开通免费 Scheduler add-on(standard)
heroku addons:create scheduler:standard -a your-app
# 非密钥配置
heroku config:set \
MAIL_FROM='Acme Reports <reports@mail.example.com>' \
DIGEST_TO='founders@example.com' \
DIGEST_KIND=daily \
-a your-app
# 密钥:交互设置,避免进入 shell 历史 / CI 日志
heroku config:set ROVELA_API_KEY= -a your-app
# 随后粘贴 re_… 的 sending_access 值,
# 或使用 Dashboard → Settings → Config Vars
ROVELA_API_KEY 优先用 Dashboard 或感知密钥的 CI 步骤。生产发信保持 sending_access——不要用 full_access(Authentication)。永不打印 API key。永不在 Scheduler 任务字符串里写 $ROVELA_API_KEY。
准备挂任务时打开面板:
heroku addons:open scheduler -a your-app
一次性 Node.js 脚本(按窗口的 Idempotency-Key)
在 bin/ 下放可执行脚本,让 Scheduler 与 heroku run 共用入口:
#!/usr/bin/env node
// bin/send-daily-digest
'use strict';
const ROVELA_URL = 'https://api.rovela.dev/emails';
function requireEnv(name) {
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) {
if (kind === 'hourly') return `heroku-hourly-ops:${utcHourKey()}`;
if (kind === 'tenmin') {
const d = new Date();
const bucket = Math.floor(d.getUTCMinutes() / 10) * 10;
const hh = String(d.getUTCHours()).padStart(2, '0');
const mm = String(bucket).padStart(2, '0');
return `heroku-tenmin-ops:${utcDayKey()}T${hh}:${mm}`;
}
return `heroku-daily-digest:${utcDayKey()}`;
}
async function buildDailyDigest(day) {
// 替换成你对 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({ to, subject, html, text, idempotencyKey, tags }) {
const res = await fetch(ROVELA_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${requireEnv('ROVELA_API_KEY')}`,
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify({
from: requireEnv('MAIL_FROM'),
to,
subject,
html,
text,
tags,
}),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(`Rovela ${res.status}: ${body.message ?? 'send failed'}`);
}
return body; // { object: 'email', id }
}
async function main() {
const 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);
console.log(
JSON.stringify({
kind,
day,
idempotencyKey,
// 永不打印 ROVELA_API_KEY
}),
);
const result = await sendTransactional({
to,
subject: digest.subject,
html: digest.html,
text: digest.text,
idempotencyKey,
tags: {
kind: `heroku-${kind}-digest`,
day,
platform: 'heroku-scheduler',
},
});
console.log(JSON.stringify({ ok: true, emailId: result.id }));
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
赋予可执行权限并提交:
chmod +x bin/send-daily-digest
git add bin/send-daily-digest
git commit -m "Add Heroku Scheduler daily digest sender"
git push heroku main
Rovela 的 Idempotency-Key(最长 256 字符)会让 24 小时内对同一请求的重试返回原始响应,而不是再发一封(Send Email API)。在 Heroku 上这很重要,因为:
- Scheduler 偶尔可能跑两次
- 区间抖动可能让两只 dyno 短暂重叠
- 手动
heroku run bin/send-daily-digest冒烟会再次进入同一日历窗口
推荐键:
| Key | 使用场景 |
|---|---|
heroku-daily-digest:{yyyy-mm-dd} | 每个 UTC 日一封运维摘要 |
heroku-hourly-ops:{yyyy-mm-dd}T{HH} | 每小时快照 |
heroku-tenmin-ops:{yyyy-mm-dd}T{HH}:{mm} | 每 10 分钟运维脉冲(mm 向下取整到 00/10/20/…) |
heroku-weekly-report:{yyyy}-W{ww} | 每个 ISO 周一封创始人报告 |
heroku-customer-digest:{userId}:{yyyy-mm-dd} | 按用户摘要(每个收件人窗口一把键) |
若要发大量个性化摘要,按收件人循环并各用一把新键(或对最多 100 封非模板消息使用 POST /emails/batch——batch 会按设计拒绝 template / topic_id(Send Email API))。限制并发;遇到 429 时退避并用同一把键重试。总墙钟时间要远小于 Scheduler 间隔,以便 dyno 干净退出。
Python 替代写法
#!/usr/bin/env python3
# bin/send_daily_digest.py
import json
import os
import sys
import urllib.error
import urllib.request
from datetime import datetime, timezone
ROVELA_URL = "https://api.rovela.dev/emails"
def require_env(name: str) -> str:
v = os.environ.get(name)
if not v:
raise SystemExit(f"missing env {name}")
return v
def utc_day_key(now: datetime | None = None) -> str:
now = now or datetime.now(timezone.utc)
return now.strftime("%Y-%m-%d")
def window_key(kind: str) -> str:
now = datetime.now(timezone.utc)
if kind == "hourly":
return f"heroku-hourly-ops:{now.strftime('%Y-%m-%dT%H')}"
if kind == "tenmin":
bucket = (now.minute // 10) * 10
return f"heroku-tenmin-ops:{now.strftime('%Y-%m-%dT%H')}:{bucket:02d}"
return f"heroku-daily-digest:{utc_day_key(now)}"
def build_daily_digest(day: str) -> dict:
stats = {"signups": 12, "paid": 3, "incidents": 1, "failedJobs": 0}
subject = f"Acme daily digest — {day}"
text = (
f"Signups: {stats['signups']}\n"
f"Paid: {stats['paid']}\n"
f"Open incidents: {stats['incidents']}\n"
f"Failed jobs: {stats['failedJobs']}"
)
html = f"""
<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": subject, "html": html, "text": text}
def send_transactional(*, to, subject, html, text, idempotency_key, tags):
payload = {
"from": require_env("MAIL_FROM"),
"to": to,
"subject": subject,
"html": html,
"text": text,
"tags": tags,
}
req = urllib.request.Request(
ROVELA_URL,
data=json.dumps(payload).encode("utf-8"),
headers={
"Authorization": f"Bearer {require_env('ROVELA_API_KEY')}",
"Content-Type": "application/json",
"Idempotency-Key": idempotency_key,
},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.load(resp)
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", errors="replace")
raise SystemExit(f"Rovela {e.code}: {body}") from e
def main() -> None:
kind = os.environ.get("DIGEST_KIND", "daily")
day = utc_day_key()
digest = build_daily_digest(day)
to = [s.strip() for s in require_env("DIGEST_TO").split(",") if s.strip()]
idem = window_key(kind)
print(json.dumps({"kind": kind, "day": day, "idempotencyKey": idem}))
result = send_transactional(
to=to,
subject=digest["subject"],
html=digest["html"],
text=digest["text"],
idempotency_key=idem,
tags={
"kind": f"heroku-{kind}-digest",
"day": day,
"platform": "heroku-scheduler",
},
)
print(json.dumps({"ok": True, "emailId": result.get("id")}))
if __name__ == "__main__":
try:
main()
except Exception as err:
print(err, file=sys.stderr)
sys.exit(1)
Rails 应用常把同样逻辑放进 lib/tasks/scheduler.rake,再调度 rake send_daily_digest——Idempotency-Key 与 POST /emails 契约保持不变。
挂上 Scheduler 任务
- 部署脚本(
git push heroku main或你的常规流水线)。 - 在 one-off dyno 上冒烟:
heroku run bin/send-daily-digest -a your-app
# 或:heroku run python bin/send_daily_digest.py -a your-app
- 打开 Scheduler,点 Add Job…:
| 字段 | 示例 |
|---|---|
| Task | bin/send-daily-digest(任何地方都不要出现 $ROVELA_API_KEY) |
| Frequency | Daily |
| Next due | 13:00 UTC(夏令时约等于美东 09:00) |
| Dyno size | 与短时 heroku run 同类 |
- 观察运行:
heroku logs --ps scheduler -a your-app -t
应看到 scheduler.X 启动、带 idempotencyKey 的 JSON 日志,以及状态变为 complete。若后续要对齐 webhook,可保存 Rovela emailId。
失败模式与处理
| 现象 | 可能原因 | 处理 |
|---|---|---|
Rovela 401 | ROVELA_API_KEY 错误/缺失,或误用只需发信却不合适的权限 | 重置 config var;确认 sending_access(Authentication) |
Rovela 400 点名域名 | MAIL_FROM 域名未验证 | 在 Rovela Domains 验证(Quick Start) |
Rovela 429 | 触达速率限制 | 退避并用同一把 Idempotency-Key 重试 |
| 日志里没有任务 | 偶发 Scheduler 跳过,或应用/频率选错 | 检查 heroku addons 与 Scheduler 面板;关键邮件改用 clock 进程 |
| 摘要重复 | 偶发双触发或重叠抖动且没有稳定键 | 使用上文按窗口的键;24h 内 Idempotency-Key 会合并重复 |
| 发送中途 dyno 被杀 | 任务长于调度间隔 | 少查一点、重活入队 worker、尽快退出 |
| 日志里出现密钥 | Scheduler Task 字符串含 $ROVELA_API_KEY | 删掉;只在脚本内读 process.env / os.environ |
上线检查清单
-
Rovela 发信域名已验证;
MAIL_FROM匹配该域 -
ROVELA_API_KEY为sending_access,仅经 config vars / Dashboard 设置 - Scheduler 任务字符串没有任何密钥插值
- 一次性命令在发送后(或空窗口主动 no-op 后)退出
-
Idempotency-Key按调度窗口作用域(日 / 小时 / 10 分钟桶) -
挂任务前
heroku run …冒烟已成功 - 每日任务已按本地挂钟换算正确的 UTC
-
已监控
scheduler.X日志(heroku logs --ps scheduler) - 关键调度已记录为自定义 clock 进程候选
-
可选:为摘要收件人配置 Rovela
email.bounced/email.complainedwebhook
FAQ
Scheduler 对这种模式是“永久免费”吗?
add-on 本身免费,但每次触发消耗的 one-off dyno 小时与 heroku run 相同(Dyno hour costs)。选合适 dyno 规格,并保持脚本短。
能每 5 分钟跑一次吗?
不能靠 Scheduler 内置频率。请用自定义 clock 进程(或其他平台的 cron),并复用同一套 Rovela 辅助函数。
该不该在 Scheduler 里再用 Rovela scheduled_at?
通常不必。若 Scheduler 已在投递时刻带着实时正文触发,直接 POST /emails。若现在接受、稍后再由 Rovela 投递、且不需醒着的 dyno,再用 scheduled_at(Scheduled Email Sending)。
sending_access 能做什么?
足够通过 POST /emails / batch 入队发信。读取、改期、取消需要 full_access 或控制台会话(Send Email API、Authentication)。Scheduler 任务请保持 sending_access。
200 表示创始人已经收到摘要了吗?
否——只表示 Rovela 已接受并入队。投递状态请用 GET /emails/:id、events 或 webhook(Quick Start)。
把调度交给 Heroku,把投递交给 Rovela
Heroku Scheduler 在 one-off dyno 上提供熟悉的粗粒度节奏。你的脚本负责实时摘要与按窗口的 Idempotency-Key。Rovela 负责 HTTPS 接受、DKIM 与投递。配好 config vars、把密钥留在任务字符串之外、干净退出,即可在不自建 SMTP、不搭邮件栈的情况下发出事务摘要。
下一步:验证域名、创建 sending_access 密钥、部署 bin/send-daily-digest、先 heroku run 一次,再挂上 Scheduler 任务。