← 返回博客

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,避免偶发双触发重复发摘要。

更新于 2026年11月5日
Heroku Scheduler 事务邮件 + Rovela(2026):One-Off Dyno、Config Vars 与按窗口的幂等键

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

  1. 把 ROVELA_API_KEY、MAIL_FROM、收件人放进 config vars(绝不进 Git,也不写进 Scheduler 任务字符串)
  2. 提供会在发送结束后退出的一次性 bin/ 脚本(或 rake / process type)
  3. 按当前调度窗口聚合摘要 / 报告
  4. 经 Rovela 发送,并使用按窗口作用域的 Idempotency-Key
  5. 在偶发双触发、手动 heroku run 冒烟、以及区间抖动重叠时,仍不重复发信

Rovela 不会创建 Heroku 应用、Scheduler 任务或 config vars。 Heroku 负责 schedule → one-off dyno;你的命令负责查询 → 映射字段 → POST /emails;Rovela 负责投递。

与下列文章区分:

也不同于 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_access config 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 发送辅助函数。

前置条件

  1. Rovela 中已验证发信域名。未验证的 from 域名会以 400 拒绝(Quick Start)。
  2. 形如 re_… 的 API key。Scheduler 任务优先 sending_access(Authentication)。
  3. 能出站 HTTPS 的 Heroku 应用(任意官方 buildpack / 能 fetch 或用 HTTP 客户端的语言)。
  4. 为 ROVELA_API_KEY、MAIL_FROM、DIGEST_TO(或在代码里查收件人)配置 config vars。
  5. 已在该应用上开通 Scheduler add-on。
  6. 成功接受后以 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 任务

  1. 部署脚本(git push heroku main 或你的常规流水线)。
  2. 在 one-off dyno 上冒烟:
heroku run bin/send-daily-digest -a your-app
# 或:heroku run python bin/send_daily_digest.py -a your-app
  1. 打开 Scheduler,点 Add Job…:
字段示例
Taskbin/send-daily-digest(任何地方都不要出现 $ROVELA_API_KEY)
FrequencyDaily
Next due13:00 UTC(夏令时约等于美东 09:00)
Dyno size与短时 heroku run 同类
  1. 观察运行:
heroku logs --ps scheduler -a your-app -t

应看到 scheduler.X 启动、带 idempotencyKey 的 JSON 日志,以及状态变为 complete。若后续要对齐 webhook,可保存 Rovela emailId。

失败模式与处理

现象可能原因处理
Rovela 401ROVELA_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.complained webhook

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 任务。