DigitalOcean App Platform Jobs 事务邮件 + Rovela(2026):SCHEDULED Cron、加密环境变量与按窗口的幂等键
把 DigitalOcean App Platform Scheduled Jobs(kind: SCHEDULED、五段 cron + time_zone、最短每 15 分钟)接到 Rovela POST /emails:用 Encrypt/SECRET 的 RUN_TIME 环境变量存放 sending_access、必须退出的 run_command,以及按窗口的 Idempotency-Key,避免重部署重复发摘要。
DigitalOcean App Platform Jobs 负责 cron —— Rovela 负责投递邮件
DigitalOcean App Platform Jobs 可以挂一个 Job 组件:按日历周期跑(kind: SCHEDULED),或围绕部署触发(PRE_DEPLOY / POST_DEPLOY / FAILED_DEPLOY)。定时任务使用五段 cron 表达式,加上 tz 数据库里的 time_zone,最短间隔为每 15 分钟。和 Worker 一样,Job 不可路由——在后台跑、不能接 HTTP——且定时运行只在执行期间计费。
这种形态适合摘要、运维日报等正文依赖触发时刻实时状态的事务邮件——注册数、失败任务、未付发票——而不是可以提前几小时用 Rovela scheduled_at 冻住的内容。
当你的一次性 run_command 调用 POST https://api.rovela.dev/emails 时,Rovela 负责入队、DKIM 签名与投递(Send Email API、Quick Start、Authentication)。App Platform 负责调度、容器生命周期与环境变量(含 Encrypt / type: SECRET)。你的命令负责聚合,以及按调度窗口稳定的 Idempotency-Key。Rovela 只负责投递。
本指南把 DigitalOcean App Platform Scheduled Jobs → Rovela 串起来,做到:
- 把
ROVELA_API_KEY、MAIL_FROM、收件人放进 Job(或应用级)环境变量——API key 勾选 Encrypt /SECRET,作用域RUN_TIME - 提供会启动、发送、退出的一次性
run_command(Job 不是常驻 Worker) - 按当前调度窗口聚合摘要 / 报告
- 经 Rovela 发送,并使用按窗口作用域的
Idempotency-Key - 在重新部署、手动重跑、窗口重叠时,仍不重复发信
Rovela 不会创建 DigitalOcean 应用、Job 组件或加密环境变量。 App Platform 负责 schedule → 容器启动/退出;你的命令负责查询 → 映射字段 → POST /emails;Rovela 负责投递。
与下列文章区分:
- Heroku Scheduler Email — 免费 Scheduler add-on,粗粒度 10 分钟/每小时/每天的 one-off dyno(不是五段 cron)
- 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 — workflow /
run_id在 CI 事件上通知 - Scheduled Email Sending — 正文已确定时用 Rovela
scheduled_at
也不同于 部署期 App Platform Job(PRE_DEPLOY / POST_DEPLOY / FAILED_DEPLOY)——它们围着部署触发,而不是日历。本文是 kind: SCHEDULED Job → run_command → Rovela 这条路径。
App Platform Jobs 做什么,Rovela 发什么
| 关注点 | 负责方 |
|---|---|
五段 cron + time_zone(最短每 15 分钟) | App Platform Job kind: SCHEDULED(manage jobs、app spec) |
跑完 run_command 即结束的不可路由容器 | App Platform |
环境变量 / Encrypt(type: SECRET)存放 ROVELA_API_KEY | 组件级或应用级 envs(environment variables) |
| 按窗口查询 DB / API | 你的 run_command |
| 渲染 HTML + text | 你的脚本 |
POST /emails、DKIM、队列、退信 | Rovela |
Idempotency-Key = do-job-{kind}:{yyyy-mm-dd}(或小时 / 15 分钟桶) | 你的脚本 |
Rovela 返回 200 表示已接受并入队,不等于「已进收件箱」(Quick Start)。
塑造发信 Job 的 App Platform 约束
按 DigitalOcean 文档,发信用的定时 Job 应把这些当作硬条件:
kind: SCHEDULED+schedule.cron+schedule.time_zone。 cron 是经典五段;time_zone来自 tz 数据库(如UTC、America/New_York、Asia/Shanghai)(manage jobs、app spec)。- 最短每 15 分钟。 比
*/15 * * * *更密的表达式不支持(manage jobs)。 - Job 不可路由。 不能接 HTTP/HTTPS——不要在 Job 上挂 webhook;在
run_command里出站 HTTPS 调 Rovela(glossary: Job)。 - 只在运行时计费。 两次定时触发之间的空闲不计入定时 Job 费用(manage jobs)。
run_command必须跑完并退出。 Job 是跑到完成的进程。保持任务短(查询 + 一次 HTTPS 发送),或把长活丢到 Worker 后退出。卡住的进程会持续消耗实例时间。- 密钥用 Encrypt /
type: SECRET。ROVELA_API_KEY优先组件级RUN_TIME加密变量。控制面板路径下加密值会从构建/部署/应用日志中隐藏,但有控制台权限的队友仍可能读到运行时环境——相应限制 Owner/Member(environment variables)。 - 控制面板或 app spec。 UI:资源类型 Job,触发 On a schedule,填 cron。YAML:
jobs[].kind: SCHEDULED并配置schedule(manage jobs)。 - 可观测性。 控制面板 Activity → Jobs,或 API /
doctl的GET /v2/apps/{app_id}/job-invocations(manage jobs)。 - 密钥只留在服务端。 优先 Rovela
sending_access——绝不放进浏览器包或公开 Git(Authentication)。 - 出站 SMTP 不合适。 优先 443 上的 HTTPS
POST /emails(Email API vs SMTP)。
何时用 App Platform Jobs + POST /emails(而不是 scheduled_at)
适合 SCHEDULED Job → 实时发送 的场景:
- HTML 必须在触发时带上新鲜指标
- 应用已在 DigitalOcean App Platform 上,希望共用同一套 app spec、envs、Activity 日志
- ≥15 分钟的 cron 且需要明确
time_zone就够用 - 发往运维 / 创始人 / 运行时查到的客户地址的少量事务邮件
正文已经正确、只想「稍后再投」且不必起容器时,用 Rovela scheduled_at(Scheduled Email Sending)。App Platform Jobs 与 scheduled_at 解决不同问题;本文是 DigitalOcean 这条路径。
只有需要常驻进程(队列消费者)时才用长期 Worker。日历摘要用跑完就退出的 SCHEDULED Job 更清晰、也更省。
前置条件
- Rovela 上已验证的发信域名。未验证的
from域名会400(Quick Start)。 - 形如
re_…的 API key。Job 优先sending_access(Authentication)。 - 能构建你的仓库 / 镜像、并以出站 HTTPS 跑短命令的 App Platform 应用。
- Job(或应用级)环境变量:
ROVELA_API_KEY(Encrypt /SECRET)、MAIL_FROM、DIGEST_TO。 - 成功接受后(或空窗口故意 no-op 后)以
0退出的run_command。
App spec:SCHEDULED Job + 加密环境变量
最小 Job 片段(挂在现有 services/workers 旁)。按你的应用改 github / git / image、environment_slug、实例规格:
jobs:
- name: daily-digest
kind: SCHEDULED
schedule:
cron: "0 13 * * *" # 下列 time_zone 下的 13:00
time_zone: America/New_York
github:
repo: your-org/your-app
branch: main
deploy_on_push: true
source_dir: /
environment_slug: node-js
# 设置后会覆盖 Dockerfile entrypoint;发送结束后必须退出
run_command: node bin/send-daily-digest.js
instance_count: 1
instance_size_slug: apps-s-1vcpu-0.5gb
envs:
- key: MAIL_FROM
scope: RUN_TIME
type: GENERAL
value: "Acme Reports <reports@mail.example.com>"
- key: DIGEST_TO
scope: RUN_TIME
type: GENERAL
value: "founders@example.com"
- key: DIGEST_KIND
scope: RUN_TIME
type: GENERAL
value: daily
- key: ROVELA_API_KEY
scope: RUN_TIME
type: SECRET
value: "re_your_sending_access_key" # 首次提交后加密;之后复用 EV[…]
用控制面板(Add components → Job → On a schedule)或:
doctl apps update <app-id> --spec ./app.yaml
ROVELA_API_KEY 优先 Dashboard Encrypt(或 type: SECRET)。生产发信方保持 sending_access——不要用 full_access(Authentication)。永远不要打印 API key。首次提交后 app spec 会存 EV[…] 密文——后续更新应提交该加密值,而不是再次粘贴明文(app spec envs)。
每 15 分钟运维脉冲示例:
schedule:
cron: "*/15 * * * *"
time_zone: UTC
一次性 Node.js 脚本(按窗口的 Idempotency-Key)
加入 Job run_command 可执行的脚本:
// bin/send-daily-digest.js
'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 `do-job-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 `do-job-fifteen-ops:${utcDayKey()}T${hh}:${mm}`;
}
return `do-job-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: `do-job-${kind}-digest`,
day,
platform: 'digitalocean-app-platform-jobs',
},
});
console.log(JSON.stringify({ ok: true, emailId: result.id }));
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
Rovela 的 Idempotency-Key(最长 256 字符)会让 24 小时内对同一次发送的重试返回原响应,而不是再发一封(Send Email API)。在 App Platform 上这很重要,因为:
- 调试
run_command时可能再次触发 Job - cron 边界与重新部署可能再次进入同一日历窗口
- 控制台 / 一次性调用可能与定时触发重叠
推荐键:
| Key | 适用场景 |
|---|---|
do-job-daily-digest:{yyyy-mm-dd} | 每个 UTC 日一份运维摘要 |
do-job-hourly-ops:{yyyy-mm-dd}T{HH} | 每小时快照(0 * * * *) |
do-job-fifteen-ops:{yyyy-mm-dd}T{HH}:{mm} | 每 15 分钟脉冲(mm 向下取整到 00/15/30/45) |
do-job-weekly-report:{yyyy}-W{ww} | 每个 ISO 周一份创始人报告 |
do-job-customer-digest:{userId}:{yyyy-mm-dd} | 按用户摘要(每个收件人窗口一把键) |
若要发大量个性化摘要,按收件人循环并各用一把新键(或对最多 100 条非模板消息用 POST /emails/batch——batch 会故意拒绝 template / topic_id(Send Email API))。控制并发;遇 429 退避并用同一把键重试。墙钟时间要短,好让 Job 容器干净退出。
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"do-job-hourly-ops:{now.strftime('%Y-%m-%dT%H')}"
if kind == "fifteen":
bucket = (now.minute // 15) * 15
return f"do-job-fifteen-ops:{now.strftime('%Y-%m-%dT%H')}:{bucket:02d}"
return f"do-job-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"do-job-{kind}-digest",
"day": day,
"platform": "digitalocean-app-platform-jobs",
},
)
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)
在 Job 上设置 run_command: python bin/send_daily_digest.py(或镜像里的解释器路径)。
挂上 Job 并验证
- 用常规 App Platform push /
doctl apps update部署脚本。 - 确认 Job 环境变量:
ROVELA_API_KEY已加密,MAIL_FROM/DIGEST_TO已设,作用域RUN_TIME。 - 在 Settings → Job → Job trigger 选 On a schedule(或在 spec 确认
kind: SCHEDULED),填 cron + 时区。 - 看 Activity → Jobs,或列出调用:
curl -s -H "Authorization: Bearer $DIGITALOCEAN_TOKEN" \
"https://api.digitalocean.com/v2/apps/<app-id>/job-invocations"
应看到 Job 启动、带 idempotencyKey 的 JSON 日志行,以及完成的调用。若之后要对照 webhook,可记下 Rovela emailId。
失败模式与处理
| 现象 | 可能原因 | 处理 |
|---|---|---|
Rovela 401 | ROVELA_API_KEY 错误/缺失,或误用 full_access | 重置加密 env;确认 sending_access(Authentication) |
Rovela 400 点名域名 | MAIL_FROM 域名未验证 | 在 Rovela Domains 验证(Quick Start) |
Rovela 429 | 限流 | 退避并用同一把 Idempotency-Key 重试 |
| Job 从不触发 | cron 密于 15 分钟、kind 错误或挂错组件 | 使用 ≥ */15;确认 kind: SCHEDULED 与 Job trigger On a schedule |
| 重复摘要 | 同一窗口重跑 / 重部署且没有稳定键 | 使用上文按窗口的键;Idempotency-Key 在 24h 内合并重复 |
| Job 持续烧实例时间 | run_command 不退出(例如起了 HTTP 服务) | 发送后退出;长活进程用 Worker |
| 密钥进了构建日志 | 作用域是 BUILD_TIME / RUN_AND_BUILD_TIME 或未 Encrypt | 使用 RUN_TIME + Encrypt / type: SECRET |
上线清单
-
Rovela 发信域名已验证;
MAIL_FROM匹配该域 -
ROVELA_API_KEY为sending_access,Encrypt /SECRET,仅RUN_TIME -
Job
kind: SCHEDULED,五段 cron ≥ 每 15 分钟,且有明确time_zone -
run_command在发送后(或空窗口故意 no-op 后)退出 -
Idempotency-Key按调度窗口(日 / 小时 / 15 分钟桶)作用域 - Activity → Jobs(或 job-invocations API)显示成功完成
- 限制团队控制台权限,保护加密 env
-
可选:对摘要收件人配置 Rovela webhook(
email.bounced/email.complained)
FAQ
能每 5 分钟跑一次吗?
不能——定时 App Platform Jobs 最短 每 15 分钟(manage jobs)。保留同一套 Rovela 辅助函数,若需要更密的节奏再换别的调度器。
要不要在 Job 里用 Rovela scheduled_at?
通常不必。若 Job 已在发送时刻带着实时正文触发,直接 POST /emails。若现在接受消息、希望 Rovela 稍后再投且不必醒着容器,再用 scheduled_at(Scheduled Email Sending)。
摘要用 Job、Worker 还是 Service?
日历摘要用 SCHEDULED Job(启动 → 退出)。常驻队列消费者用 Worker。HTTP API 才用 Service——Job 不可路由(glossary: Job)。
sending_access 覆盖哪些能力?
足够通过 POST /emails / batch 入队发信。读消息、改期、取消需要 full_access 或仪表盘会话(Send Email API、Authentication)。Job 保持 sending_access。
200 是否表示创始人已经收到摘要?
否——表示 Rovela 已接受并入队。投递状态用 GET /emails/:id、事件或 webhook(Quick Start)。
把节奏交给平台,把投递给 Rovela
DigitalOcean App Platform Jobs 在短命、不可路由的容器上提供带 time_zone 的一等公民五段 cron。你的 run_command 负责实时摘要与按窗口的 Idempotency-Key。Rovela 负责 HTTPS 接受、DKIM 与投递。加密发信密钥、干净退出,就能在不搭 SMTP 或自建邮件栈的情况下发出事务摘要。
下一步:验证域名,创建 sending_access 密钥,加上带加密环境变量的 kind: SCHEDULED Job,部署一次性脚本,并在 Activity → Jobs 确认首次调用。