← 返回博客

Railway Cron 事务邮件 + Rovela(2026):Secrets 与按窗口的幂等键

在 Railway 上跑会干净退出的 cron 服务,从 Railway 变量读取 ROVELA_API_KEY,并用对齐 UTC 调度窗口的 Idempotency-Key 调用 POST /emails。与 Vercel Cron 路由、GitHub Actions run_id 通知区分。

更新于 2026年11月4日
Railway Cron 事务邮件 + Rovela(2026):Secrets 与按窗口的幂等键

Railway cron 负责跑任务 — Rovela 负责投递邮件

Railway Cron Jobs 会按 UTC crontab 启动一个 service,执行其 start command,并期望该进程 跑完后退出。这很适合摘要、运维报告以及其他正文依赖 触发时刻现场状态 的事务邮件——注册数、失败任务、未付账单——而不是能提前几小时用 Rovela scheduled_at 冻住的内容。

当你的 cron 进程调用 POST https://api.rovela.dev/emails 时,Rovela 负责入队、DKIM 签名与投递(Send Email API、Quick Start、Authentication)。Railway 负责调度、一次性部署,以及 共享变量 / secrets。你的代码负责聚合,以及按调度窗口稳定的 Idempotency-Key。Rovela 只负责投递。

本指南会搭好一个 短生命周期的 Railway cron 服务,它会:

  1. 从 Railway 读取 ROVELA_API_KEY、MAIL_FROM 与收件人环境变量(绝不写死)
  2. 为当前 UTC 窗口聚合 digest / 报告
  3. 经 Rovela 发送,并用该窗口作为 Idempotency-Key
  4. 关闭连接并 退出,以免下次调度被跳过

Rovela 不会创建 Railway cron 计划、不会设置 Railway 变量,也不会替你保活进程。 Railway 负责 start → exit。你的 start command 负责查询 → 映射字段 → POST /emails。Rovela 负责投递。

与以下文章区分:

Railway cron 做什么,Rovela 发什么

职责归属
五段 crontab(UTC),间隔至少 5 分钟Railway(Cron Jobs)
Start command → 每次触发一次部署Railway
共享变量 / secrets(ROVELA_API_KEY 等)Railway + 你的服务配置
查询该窗口的 DB / API你的 cron 进程
渲染 HTML + text你的 cron 进程
POST /emails、DKIM、队列、退信Rovela
Idempotency-Key = railway-{kind}-{yyyy-mm-dd}(或小时 / 周)你的 start command
进程必须退出;重叠运行会被跳过Railway + 你的代码

Rovela 返回 200 表示 已接受并入队,不等于「已经进收件箱」(Quick Start)。

影响发信任务的 Railway 约束

按 Railway 文档,把下面几条当作发信 cron 的硬要求:

  • 做完就退出。 关掉 DB 连接池、HTTP client,以及任何会挂住事件循环的资源。若上一次执行仍是 Active,Railway 会 跳过 下一次触发(Cron Jobs)。
  • 调度按 UTC。 写表达式时自己换算本地「周一早上 9 点」。
  • 两次运行之间至少五分钟;会产生更短间隔的表达式无效。
  • 触发时间可能漂移几分钟——不要假设秒级精度。幂等键应对齐 日历窗口,而不是「crontab 的精确秒」。
  • 用 railway cron list / railway cron run 查看或在计划外触发(CLI cron)。

短任务(日摘要、小时级运维快照)用 Railway cron;不能因重叠被跳过、或需要小于五分钟频率时,改用常驻 worker 或队列(Cron vs workers vs queues)。

何时用 Railway cron + POST /emails(而不是 scheduled_at)

适合 Railway cron → 现场发送 的情况:

  • HTML 必须在触发时带上 最新 指标
  • 你已在 Railway 托管 DB / worker,希望调度贴近数据
  • 一两封事务邮件发给固定运维 / 创始人 / 客户地址(运行时查找亦可)

正文已经正确、只需要「稍后再投递」且不想跑任务时,用 Rovela scheduled_at(Scheduled Email Sending)。Cron 与 scheduled_at 解决不同问题;本文走 Railway cron 路径。

前置条件

  1. 在 Rovela 中 验证发信域名。未验证的 from 域名会以 400 拒绝(Quick Start)。
  2. 形如 re_… 的 API key。cron 服务优先用 sending_access(Authentication)。
  3. Railway 项目中有专门跑 cron 的服务(或 start command 只做这一件事)。
  4. 该服务 / 环境上的变量:ROVELA_API_KEY、MAIL_FROM、DIGEST_TO(或在代码里查收件人),以及 DB / API 凭证。
  5. 在 Settings → Cron Schedule 配置 UTC crontab,两次触发间隔 ≥ 5 分钟。

在 Railway 上配置环境变量

在 Railway 控制台(服务 → Variables)或 CLI 中设置:

ROVELA_API_KEY=re_your_sending_access_key
MAIL_FROM='Acme Reports <reports@mail.example.com>'
DIGEST_TO='founders@example.com'
# 再加上 DATABASE_URL / digest 查询所需的其他变量

把 API key 标成 secret / sensitive,避免进日志。不要提交到仓库。轮换 Rovela key 的顺序是:创建 → 更新 Railway 变量 → 重新部署 / 等下次 cron → 再吊销旧 key(Authentication)。

配置 cron 计划

  1. 创建或选择将跑发信任务的服务。
  2. 把 start command 设为一锤子入口(见下方示例)。
  3. 打开 Settings → Cron Schedule,填入五段 UTC 表达式(Cron Jobs)。

示例:

表达式含义(UTC)
0 13 * * *每天 13:00
0 */6 * * *每六小时整点
*/15 * * * *每 15 分钟(≥ 5 分钟合法)

不要把 Web 服务器(node server.js、uvicorn --reload 等)当作 cron 服务的 start command。进程必须结束。

最小 Node cron 入口

package.json 的 start(或 Railway start command)应跑一个会退出的脚本:

{
  "name": "railway-digest-cron",
  "type": "module",
  "scripts": {
    "start": "node src/digest-cron.mjs"
  },
  "engines": { "node": ">=20" }
}
// src/digest-cron.mjs
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
}

async function buildDailyDigest(day) {
  // 换成你对 Railway 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({ 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 day = utcDayKey()
  const digest = await buildDailyDigest(day)
  const to = requireEnv('DIGEST_TO').split(',').map((s) => s.trim())

  // 每个产品每个 UTC 日一封逻辑邮件 — Railway 或你手动重跑也安全
  const result = await sendTransactional({
    to,
    subject: digest.subject,
    html: digest.html,
    text: digest.text,
    idempotencyKey: `railway-daily-digest:${day}`,
    tags: { kind: 'railway-daily-digest', day, platform: 'railway' },
  })

  console.log(JSON.stringify({ ok: true, day, emailId: result.id, stats: digest.stats }))
}

main()
  .then(() => process.exit(0))
  .catch((err) => {
    console.error(err)
    process.exit(1)
  })

Rovela 的 Idempotency-Key(最长 256 字符)会让 同一 次发送在 24 小时 内的重试返回原始响应,而不是再发一封(Send Email API)。在 Railway 上这很重要:你可以用 railway cron run 重跑,网络抖动也可能重试自己的 fetch。

推荐键:

Key适用
railway-daily-digest:{yyyy-mm-dd}每个 UTC 日一封运维摘要
railway-hourly-ops:{yyyy-mm-dd}T{HH}小时快照(对齐 0 * * * *)
railway-weekly-report:{yyyy}-W{ww}每个 ISO 周一封创始人报告
railway-customer-digest:{userId}:{yyyy-mm-dd}按用户摘要(每个收件人窗口一把键)

若要发 大量 个性化摘要,为每个收件人使用新键循环(或对最多 100 封非模板消息用 POST /emails/batch——batch 会按设计拒绝 template / topic_id(Send Email API))。限制并发;遇到 429 时退避并用 同一 把键重试。

Python 备选(干净退出)

# digest_cron.py
import json
import os
import sys
from datetime import datetime, timezone

import urllib.request

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() -> str:
    return datetime.now(timezone.utc).strftime("%Y-%m-%d")


def build_daily_digest(day: str) -> dict:
    stats = {"signups": 12, "paid": 3, "incidents": 1, "failedJobs": 0}
    subject = f"Acme daily digest — {day}"
    text = "\n".join(
        [
            f"Signups: {stats['signups']}",
            f"Paid: {stats['paid']}",
            f"Open incidents: {stats['incidents']}",
            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>
    """
    return {"subject": subject, "html": html, "text": text, "stats": stats}


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(),
        headers={
            "Authorization": f"Bearer {require_env('ROVELA_API_KEY')}",
            "Content-Type": "application/json",
            "Idempotency-Key": idempotency_key,
        },
        method="POST",
    )
    with urllib.request.urlopen(req, timeout=30) as resp:
        return json.loads(resp.read().decode())


def main() -> None:
    day = utc_day_key()
    digest = build_daily_digest(day)
    to = [s.strip() for s in require_env("DIGEST_TO").split(",") if s.strip()]
    result = send_transactional(
        to=to,
        subject=digest["subject"],
        html=digest["html"],
        text=digest["text"],
        idempotency_key=f"railway-daily-digest:{day}",
        tags={"kind": "railway-daily-digest", "day": day, "platform": "railway"},
    )
    print(json.dumps({"ok": True, "day": day, "emailId": result["id"], "stats": digest["stats"]}))


if __name__ == "__main__":
    try:
        main()
    except Exception as e:
        print(e, file=sys.stderr)
        sys.exit(1)

Railway start command 示例:python digest_cron.py。确保进程不会留下未关闭的 Postgres 连接——返回前关掉连接池 / 用上下文管理器。

保持正文事务性

发给团队或单个客户、关于 其账户 的摘要属于事务邮件。不要把营销群发塞进 cron 路径——营销活动应走营销工具与已验证列表。同时发送 html 与 text;把数字放进主题或 text 前几行,方便通知预览。

可选:控制台模板

若版式稳定,可在 Rovela 控制台存模板并传变量(与 POST /emails 上的内联 html / text 互斥):

body: JSON.stringify({
  from: process.env.MAIL_FROM,
  to: process.env.DIGEST_TO,
  template: {
    id: process.env.ROVELA_DIGEST_TEMPLATE_ID,
    variables: {
      day,
      signups: String(stats.signups),
      paid: String(stats.paid),
      incidents: String(stats.incidents),
    },
  },
})

Batch 不能使用 template——每封模板邮件用单次 POST /emails(Send Email API)。

不等计划到来就测试

变量与 start command 就绪后:

railway cron run -y
railway cron list

期望一次成功运行、日志里一个 Rovela message id、以及一封进箱。同一 UTC 日用窗口级 Idempotency-Key 再跑一次,会在 24 小时内返回缓存结果而不是第二封。

值得处理的失败模式

现象可能原因
Cron「成功」但没邮件脚本在 POST /emails 前就打了成功日志,或吞掉了错误——先 await 发送,失败时非零退出
下次计划被跳过上一次部署仍 Active——泄漏的 DB/HTTP 句柄、缺少 process.exit、挂起的 promise
Rovela 400 点名域名from 不在已验证域名上(Quick Start)
Rovela 401ROVELA_API_KEY 错误 / 已吊销,或密钥类型不适配该调用
Rovela 429触达限流——退避并用 同一 Idempotency-Key 重试
重复摘要键太粗(缺 day/hour)或每次随机生成
任务从不跑服务上没有 cron schedule、表达式非法(间隔 < 5 分钟)、或环境选错

生产可见性请订阅 Rovela webhook(email.delivered、email.bounced 等),不要在 cron 里轮询 GET /emails/:id(读取需要 full_access 或会话;sending_access 只能发送(Authentication))。

部署清单

  1. 在 Rovela 验证发信域名;按控制台确认 SPF / DKIM / DMARC / return-path。
  2. 创建 sending_access key;在 cron 服务上设置 ROVELA_API_KEY、MAIL_FROM、DIGEST_TO。
  3. 将 start command 设为成功退出 0、发送失败非零退出的一锤子脚本。
  4. 设置 Cron Schedule(UTC,间隔 ≥ 5 分钟);部署。
  5. 执行一次 railway cron run;确认日志里只有一个 Rovela id,且部署进入终态(没有卡在 Active)。
  6. 让计划触发一次;同一窗口再触发时确认幂等回放。

小结

把 计划与密钥放在 Railway,把 需要退出的现场聚合放在进程里,把 发送放在 Rovela POST /emails,并用对齐 cron 窗口的 Idempotency-Key。这与 Vercel 的 HTTP cron 路由、GitHub Actions 基于 run_id 的通知都不同——也能让事务摘要远离你必须在短生命周期 Railway 部署里照看的 SMTP 套接字。