← 返回博客

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,避免重部署重复发摘要。

更新于 2026年11月5日
DigitalOcean App Platform Jobs 事务邮件 + Rovela(2026):SCHEDULED Cron、加密环境变量与按窗口的幂等键

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

  1. 把 ROVELA_API_KEY、MAIL_FROM、收件人放进 Job(或应用级)环境变量——API key 勾选 Encrypt / SECRET,作用域 RUN_TIME
  2. 提供会启动、发送、退出的一次性 run_command(Job 不是常驻 Worker)
  3. 按当前调度窗口聚合摘要 / 报告
  4. 经 Rovela 发送,并使用按窗口作用域的 Idempotency-Key
  5. 在重新部署、手动重跑、窗口重叠时,仍不重复发信

Rovela 不会创建 DigitalOcean 应用、Job 组件或加密环境变量。 App Platform 负责 schedule → 容器启动/退出;你的命令负责查询 → 映射字段 → POST /emails;Rovela 负责投递。

与下列文章区分:

也不同于 部署期 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 更清晰、也更省。

前置条件

  1. Rovela 上已验证的发信域名。未验证的 from 域名会 400(Quick Start)。
  2. 形如 re_… 的 API key。Job 优先 sending_access(Authentication)。
  3. 能构建你的仓库 / 镜像、并以出站 HTTPS 跑短命令的 App Platform 应用。
  4. Job(或应用级)环境变量:ROVELA_API_KEY(Encrypt / SECRET)、MAIL_FROM、DIGEST_TO。
  5. 成功接受后(或空窗口故意 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 并验证

  1. 用常规 App Platform push / doctl apps update 部署脚本。
  2. 确认 Job 环境变量:ROVELA_API_KEY 已加密,MAIL_FROM / DIGEST_TO 已设,作用域 RUN_TIME。
  3. 在 Settings → Job → Job trigger 选 On a schedule(或在 spec 确认 kind: SCHEDULED),填 cron + 时区。
  4. 看 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 401ROVELA_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 确认首次调用。