Fly Scheduled Machines 事务邮件 + Rovela(2026):fly secrets 与按窗口的幂等键
用 Fly Scheduled Machine(hourly/daily/weekly/monthly)跑启动后退出的任务,从 fly secrets 读取 ROVELA_API_KEY,并用对齐调度窗口的 Idempotency-Key 调用 POST /emails。与 Render Cron、Railway cron、Vercel Cron 路由、GitHub Actions 区分。
Fly Scheduled Machines 负责间隔调度 — Rovela 负责投递
Fly Machines 可通过 fly machine run --schedule 或 Machines API 的 config.schedule,按 粗粒度间隔 唤醒:hourly、daily、weekly、monthly。这种「启动 → 干活 → 退出」的形态,适合摘要、运维报告,以及正文依赖 触发时刻实时状态(注册数、失败任务、未付发票)的事务邮件——而不是你能提前几小时用 Rovela scheduled_at 冻住的内容。
当调度进程调用 POST https://api.rovela.dev/emails 时,Rovela 负责入队、DKIM 签名与投递(Send Email API、Quick Start、Authentication)。Fly 负责 Machine 生命周期,并把 app secrets 注入为环境变量。你的代码负责聚合数据,以及按调度窗口稳定的 Idempotency-Key。Rovela 只负责投递。
本指南会接好一台 Fly Scheduled Machine,让它:
- 从
fly secrets读取ROVELA_API_KEY、MAIL_FROM与收件人(绝不写死在仓库) - 按当前 UTC 窗口聚合摘要 / 报告
- 用对齐该窗口的
Idempotency-Key调用 Rovela 发送 - 干净退出,让 Machine 停到下一次间隔
Rovela 不会创建 Fly Machine、不会设置 Fly 调度,也不会替你保活进程。 Fly 管启动 → 退出;你的命令管查询 → 映射字段 → POST /emails;Rovela 管投递。
与下列路径区分:
- Render Cron 事务邮件 — 五段 UTC crontab + 会退出的命令;有活动运行时下一次调度会 延迟
- Railway Cron 事务邮件 — Railway cron 服务;重叠触发会被 跳过
- Vercel Cron 摘要邮件 — 对生产路由发 HTTP
GET+CRON_SECRET,不是 Fly Machine 进程 - GitHub Actions 事务邮件 — 工作流 /
run_id在 CI 事件上通知,不是平台 Machine 调度 - 定时发送邮件 — 正文已知时用 Rovela
scheduled_at
Fly 调度做什么,Rovela 发什么
| 关注点 | 归属 |
|---|---|
间隔桶(hourly / daily / weekly / monthly) | Fly(fly machine run --schedule、任务调度指南) |
| 启动、工作并退出的进程 | Fly Machine + 你的镜像 / 命令 |
以环境变量注入的密钥(fly secrets set) | Fly vault + 你的应用(Secrets) |
| 按窗口查询 DB / API | 你的调度进程 |
| 渲染 HTML + text | 你的调度进程 |
POST /emails、DKIM、队列、退信 | Rovela |
Idempotency-Key = fly-{kind}-{yyyy-mm-dd}(或小时 / 周) | 你的命令 |
精确 crontab(0 13 * * *) | 不是 Scheduled Machines — 用 Cron Manager 或 Supercronic |
Rovela 返回 200 表示 已接受并入队,不等于「已经进收件箱」(Quick Start)。
决定邮件任务形态的 Fly 约束
结合 Fly 文档与社区说明,发信调度请把下面当作硬约束:
- 只有间隔桶,没有 cron 表达式。
--schedule仅接受hourly、daily、weekly、monthly,没有分钟字段,也没有「每周三 09:00」(社区公告、任务调度指南)。 - 从创建时刻起算。 间隔相对 Machine 创建时间 计算,不是对齐挂钟整点。「daily」≈ 创建后大约每天一次,不是「每天 13:00 UTC」。
- 创建时为 stopped,到点再 started。 带 schedule 的 Machine 创建后处于 stopped,每个间隔再进入 started(社区公告)。
- 带 schedule 的默认重启策略是
on-failure。 干净退出(0)可让 Machine 停到下一次 tick;崩溃会在 Fly 的重试窗口内重试(Machine restart policy)。摘要任务不要设成always,除非你想无限循环。 - Scheduled Machine 不能靠手动 start 调试。 它们只按调度跑——不能像 Render 的 Trigger Run 那样稳定地
fly machine start(社区)。开发时用非 schedule 的 Machine,或一次性fly machine run --rm。 - 密钥按 app 作用域。
fly secrets set写入加密 vault,并在该 app 的每台 Machine 启动时注入环境变量(Secrets)。更新密钥会重启 Machine(除非--stage);绝不要把密钥打到日志。
Scheduled Machines 适合低频、近似任务(「大概每天一次就够」)。需要真 crontab 时,用 Cron Manager(按 schedules.json 拉起隔离的一次性 Machine)或进程内的 Supercronic(任务调度指南)。本文聚焦 Scheduled Machines + Rovela;同一套 POST /emails + 窗口级 Idempotency-Key 也适用于 Cron Manager / Supercronic。
何时用 Fly 调度 + POST /emails(而不是 scheduled_at)
适合 Fly Scheduled Machine → 实时发送 的情况:
- HTML 必须包含触发时刻的 新鲜 指标
- 你已在 Fly 上跑 Machines,希望调度贴近同一计算 / 区域
- 一封(或少量)事务邮件发到运行时查到的运维 / 创始人 / 客户地址
- 「大约每小时 / 每天 / 每周 / 每月一次」精度足够
正文已经正确、只需要「稍后再投」且不必跑任务时,用 Rovela scheduled_at(定时发送邮件)。平台调度与 scheduled_at 解决不同问题;本文是 Fly Machines 路径。
需要 0 13 * * MON 这种精度时,改用 Cron Manager 或 Supercronic,再从任务命令里同样调用 Rovela。
前置条件
- Rovela 上已 验证发信域名。未验证的
from域名会以400拒绝(Quick Start)。 - 形如
re_…的 API key。Machine 上优先用sending_access(Authentication)。 - 一个 Fly app,以及入口在摘要完成后会 退出 的 Docker 镜像(或 Nixpacks / Dockerfile 构建)。
- 该 app 上的密钥:
ROVELA_API_KEY、MAIL_FROM、DIGEST_TO(或在代码里查收件人),外加 DB / API 凭证。 - 选定间隔桶:
hourly、daily、weekly或monthly。
在 Fly 上放密钥
用 fly secrets set 设置(fly secrets list 不会再显示明文):
fly secrets set \
ROVELA_API_KEY=re_your_sending_access_key \
MAIL_FROM='Acme Reports <reports@mail.example.com>' \
DIGEST_TO='founders@example.com' \
-a your-digest-app
或先 stage,再在合适时机部署:
fly secrets set ROVELA_API_KEY=re_your_sending_access_key --stage -a your-digest-app
fly secrets deploy -a your-digest-app
密钥不要进 Git,也不要进镜像层。生产发送只用 sending_access,不要用 full_access(Authentication)。绝不要 console.log API key。轮换流程:新建 → fly secrets set → 等下一次调度跑通 → 吊销旧 key。
创建 Scheduled Machine
在 fly apps create / 推送镜像之后,用 flyctl 的大致路径:
# 先构建并推送镜像(fly deploy、fly deploy --build-only,或 registry push)
fly machine run registry.fly.io/your-digest-app:latest \
--schedule daily \
--restart no \
--region iad \
-a your-digest-app \
--env DIGEST_KIND=daily
与文档对齐的注意点:
--schedule daily(或hourly/weekly/monthly)是内置调度(fly machine run)。- 显式选择允许成功后停机的重启策略。带 schedule 的默认是
on-failure;一次性摘要若不想在成功后循环,心智模型用no(或保证退出码0并配合on-failure)(restart policy)。 - 即使用
fly machine run创建,app secrets 仍会注入为环境变量(Secrets)。 - 用
fly machine status与 Machine 日志观察;Machine 创建时为 stopped,到间隔再启动。
Machines API 示意(字段请以当前 Machines API 文档为准):
{
"name": "daily-digest-email",
"config": {
"image": "registry.fly.io/your-digest-app:latest",
"schedule": "daily",
"restart": { "policy": "no" },
"env": { "DIGEST_KIND": "daily" },
"guest": { "cpu_kind": "shared", "cpus": 1, "memory_mb": 256 }
}
}
需要 精确 cron 时,部署 Cron Manager,在 schedules.json 里让 command 跑同一份摘要脚本——仍然用窗口级 Idempotency-Key 调 Rovela。
最小 Node 摘要入口
{
"name": "fly-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
}
function utcHourKey(d = new Date()) {
return d.toISOString().slice(0, 13) // YYYY-MM-DDTHH
}
async function buildDailyDigest(day) {
// 替换成你对 Fly 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 kind = process.env.DIGEST_KIND || 'daily'
const day = utcDayKey()
const digest = await buildDailyDigest(day)
const to = requireEnv('DIGEST_TO').split(',').map((s) => s.trim())
// 窗口键 — 不要用 Date.now() / Machine id — 让重试与迟到 tick 仍是同一封逻辑邮件
const idempotencyKey =
kind === 'hourly'
? `fly-hourly-ops:${utcHourKey()}`
: `fly-daily-digest:${day}`
const result = await sendTransactional({
to,
subject: digest.subject,
html: digest.html,
text: digest.text,
idempotencyKey,
tags: { kind: `fly-${kind}-digest`, day, platform: 'fly' },
})
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)。在 Fly 上这很重要,因为:
on-failure下的崩溃重试可能再次进入进程- 网络抖动可能重试
fetch - Cron Manager / 手动一次性任务可能重跑同一日历窗口
- 间隔 tick 是近似的——键应对齐 日历窗口,而不是 Machine 启动的那一秒
推荐键:
| Key | 适用 |
|---|---|
fly-daily-digest:{yyyy-mm-dd} | 每个 UTC 日一封运维摘要(--schedule daily) |
fly-hourly-ops:{yyyy-mm-dd}T{HH} | 每小时快照(--schedule hourly) |
fly-weekly-report:{yyyy}-W{ww} | 每个 ISO 周一封创始人报告(--schedule weekly) |
fly-customer-digest:{userId}:{yyyy-mm-dd} | 按用户摘要(每个收件人窗口一把钥匙) |
若要发 大量 个性化摘要,按收件人循环并各自使用新 key(或对最多 100 封非模板消息使用 POST /emails/batch——batch 按设计拒绝 template / topic_id(Send Email API))。限制并发;遇到 429 退避并用 同一 key 重试。
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"fly-daily-digest:{day}",
tags={"kind": "fly-daily-digest", "day": day, "platform": "fly"},
)
print(json.dumps({"ok": True, "day": day, "emailId": result.get("id"), "stats": digest["stats"]}))
if __name__ == "__main__":
try:
main()
except Exception as err:
print(err, file=sys.stderr)
raise SystemExit(1)
把镜像的 CMD / ENTRYPOINT 指到 python digest_cron.py(镜像需已安装依赖)。进程必须结束。
Cron Manager 与 Supercronic(桶不够用时)
若业务需要 整点 13:00 UTC,Scheduled Machines 不是正确杠杆。Fly 自己的指南建议:
| 方案 | 适合邮件摘要的程度 |
|---|---|
| Scheduled Machines | 近似的每小时 / 每天 / 每周 / 每月 |
| Cron Manager | 从 Git 版本化的 schedules.json 拉起隔离的一次性 Machine |
| Supercronic | 在一台常驻(或 process group)Machine 内跑 crontab — 只保留 一个 cron 进程 |
无论谁触发任务,Rovela 契约不变:经 fly secrets 注入的 sending_access、POST /emails,以及对齐逻辑窗口(而不是 Machine id)的 Idempotency-Key。
故障排查
| 现象 | 检查 |
|---|---|
Rovela 400 点名某个域名 | MAIL_FROM 域名未在 Rovela 验证 |
Rovela 401 | Fly app 上缺少 / 错误的 ROVELA_API_KEY secret |
Rovela 429 | 退避;用 同一 Idempotency-Key 重试 |
| Machine 不断重启 | 成功时退出 0;摘要任务避免 restart: always |
| 无法手动 start scheduled Machine | 预期行为 — 调试时用非 schedule 的 --rm Machine |
| 崩溃重试后重复发信 | Key 必须按窗口稳定(fly-daily-digest:{day}),不要每次启动 uuid() |
| 需要精确挂钟时间 | 改用 Cron Manager / Supercronic;Rovela key 仍按窗口 |
非 2xx 时原样记录 Rovela 的 message 字段(Quick Start)。生产投递状态优先用 webhook,而不是轮询 GET /emails/:id——读事件需要 full_access,而 sending_access 只能发信(Send Email API、Authentication)。
清单
- 已验证 Rovela 发信域名;
sending_accesskey 在fly secrets中,不在 Git - 有意识地选择 Scheduled Machine 的
--schedule桶(或 Cron Manager / Supercronic 做真 cron) - 入口成功时退出
0;重启策略不会无限循环 - 触发时实时聚合;尽量同时设置 HTML + text
Idempotency-Key对齐调度窗口(fly-…:{day})- 用 Machine status / 日志看进程健康;用 Rovela 控制台或 webhook 看投递