DigitalOcean App Platform Jobs Transactional Email with Rovela (2026): SCHEDULED Cron + Encrypted Env + Idempotency-Key per Window
Wire DigitalOcean App Platform Scheduled Jobs (kind: SCHEDULED, five-field cron + time_zone, min every 15 minutes) to Rovela POST /emails: Encrypt/SECRET RUN_TIME env for sending_access, a run_command that exits, and a window-scoped Idempotency-Key so redeploys do not duplicate digests.
DigitalOcean App Platform Jobs own the cron — Rovela delivers the mail
DigitalOcean App Platform Jobs let you attach a Job component that runs on a recurring schedule (kind: SCHEDULED) or around deploys (PRE_DEPLOY / POST_DEPLOY / FAILED_DEPLOY). Scheduled jobs use a five-field cron expression plus a tz-database time_zone, with a minimum interval of every 15 minutes. Like workers, jobs are not routable — they run in the background and cannot accept HTTP — and scheduled runs are billed only while the job is executing.
That shape fits digests, ops reports, and other transactional mail whose body depends on live state at fire time — signup counts, failed jobs, unpaid invoices — rather than content you can freeze hours earlier with Rovela scheduled_at.
Rovela queues, DKIM-signs, and delivers when your one-shot run_command calls POST https://api.rovela.dev/emails (Send Email API, Quick Start, Authentication). App Platform owns the schedule, container lifecycle, and environment variables (including Encrypt / type: SECRET). Your command owns aggregation and a stable Idempotency-Key per schedule window. Rovela only delivers.
This guide wires DigitalOcean App Platform Scheduled Jobs → Rovela that:
- Stores
ROVELA_API_KEY,MAIL_FROM, and recipients in Job (or app-level) env vars — mark the API key Encrypt /SECRET, scopeRUN_TIME - Ships a one-shot
run_commandthat starts, sends, and exits (jobs are not long-running workers) - Aggregates a digest / report for the current schedule window
- Sends via Rovela with a window-scoped
Idempotency-Key - Survives redeploys, manual re-runs, and overlapping ticks without duplicate mail
Rovela does not create DigitalOcean apps, Job components, or encrypted env vars. App Platform owns schedule → container start/exit. Your command owns query → map fields → POST /emails. Rovela owns delivery.
Distinct from:
- Heroku Scheduler Email — free Scheduler add-on, coarse 10-min/hourly/daily one-off dynos (not five-field cron)
- Cloudflare Workers Cron Triggers Email — Workers
triggers.crons+scheduled()(UTC five-field cron) - Azure Functions Timer Email — Azure NCRONTAB (seconds-first) + App Settings / Key Vault
- GCP Cloud Scheduler Email — five-field unix-cron / groc + HTTP/OIDC or Pub/Sub
- AWS EventBridge Scheduler Email — six-field AWS cron /
rate/at+ Lambda - Netlify Scheduled Functions Email — Netlify UTC cron + short function limit
- Fly Scheduled Machines Email — coarse Machine intervals
- Render Cron Transactional Email — Render Cron Job process that must exit
- Railway Cron Transactional Email — Railway cron service
- Vercel Cron Digest Email — HTTP
GETto a production route +CRON_SECRET - GitHub Actions Transactional Email — workflow /
run_idnotify on CI events - Scheduled Email Sending — Rovela
scheduled_atwhen the body is already known
Also distinct from deploy-time App Platform jobs (PRE_DEPLOY / POST_DEPLOY / FAILED_DEPLOY) — those fire around deployments, not on a calendar. This post is the kind: SCHEDULED Job → run_command → Rovela path.
What App Platform Jobs do vs what Rovela sends
| Concern | Owner |
|---|---|
Five-field cron + time_zone (min every 15 minutes) | App Platform Job kind: SCHEDULED (manage jobs, app spec) |
Non-routable container that runs run_command to completion | App Platform |
Env vars / Encrypt (type: SECRET) for ROVELA_API_KEY | Component or app-level envs (environment variables) |
| Query DB / APIs for the window | Your run_command |
| Render HTML + text | Your script |
POST /emails, DKIM, queue, bounces | Rovela |
Idempotency-Key = do-job-{kind}:{yyyy-mm-dd} (or hour / 15-min bucket) | Your script |
A 200 from Rovela means accepted and queued, not “already in the inbox” (Quick Start).
App Platform constraints that shape the email job
From DigitalOcean docs, treat these as hard requirements for a mail-sending scheduled Job:
kind: SCHEDULED+schedule.cron+schedule.time_zone. Cron is the classic five-field form;time_zonecomes from the tz database (for exampleUTC,America/New_York,Asia/Shanghai) (manage jobs, app spec).- Minimum every 15 minutes. Expressions denser than
*/15 * * * *are not supported for scheduled jobs (manage jobs). - Jobs are not routable. They cannot accept HTTP/HTTPS — do not put a webhook receiver on the Job itself; call outbound HTTPS to Rovela from inside
run_command(glossary: Job). - Billed only while running. Idle time between scheduled fires is not billed for scheduled jobs (manage jobs).
run_commandmust finish and exit. Jobs run a process to completion. Keep the job short (query + one HTTPS send), or enqueue longer work onto a Worker and exit. A stuck process burns instance time.- Secrets via Encrypt /
type: SECRET. Prefer component-levelRUN_TIMEencrypted vars forROVELA_API_KEY. Encrypted values are hidden from build/deploy/app logs in the control panel flow, but teammates with console access can still read the runtime env — limit Owner/Member roles accordingly (environment variables). - Control panel or app spec. In the UI: Resource type Job, Job trigger On a schedule, set the cron. In YAML:
jobs[].kind: SCHEDULEDwithschedule(manage jobs). - Observability. Activity → Jobs in the control panel, or
GET /v2/apps/{app_id}/job-invocationsvia the API /doctl(manage jobs). - Secrets stay server-side. Prefer a Rovela
sending_accesskey — never ship it in a browser bundle or public Git (Authentication). - Outbound SMTP is a poor fit. Prefer HTTPS
POST /emailson 443 (Email API vs SMTP).
When to use App Platform Jobs + POST /emails (not scheduled_at)
Use SCHEDULED Job → live send when:
- The HTML must include fresh metrics at fire time
- You already run the app on DigitalOcean App Platform and want the same app spec, envs, and Activity logs
- A ≥15-minute cron with an explicit
time_zoneis enough - One (or a few) transactional messages go to ops / founder / customer addresses looked up at run time
Use Rovela scheduled_at when the body is already correct and you only need “deliver later” without a running job (Scheduled Email Sending). App Platform Jobs and scheduled_at solve different problems; this post is the DigitalOcean path.
Use a long-lived Worker only when you need a continuous process (queue consumer). For calendar digests, a SCHEDULED Job that exits is the cheaper, clearer fit.
Prerequisites
- A verified sending domain in Rovela. Unverified
fromdomains are refused with400(Quick Start). - An API key shaped like
re_…. Prefersending_accessfor the Job (Authentication). - A DigitalOcean App Platform app that can build your repo / image and run a short command with outbound HTTPS.
- Job (or app-level) env vars for
ROVELA_API_KEY(Encrypt /SECRET),MAIL_FROM,DIGEST_TO. - A
run_commandthat exits0after a successful accept (or a deliberate no-op when there is nothing to mail).
App spec: SCHEDULED Job + encrypted env
Minimal job fragment (attach alongside your existing services/workers). Adjust github / git / image, environment_slug, and instance size to match your app:
jobs:
- name: daily-digest
kind: SCHEDULED
schedule:
cron: "0 13 * * *" # 13:00 in time_zone below
time_zone: America/New_York
github:
repo: your-org/your-app
branch: main
deploy_on_push: true
source_dir: /
environment_slug: node-js
# Overrides Dockerfile entrypoint when set; must exit when send finishes
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" # encrypted on first submit; reuse EV[…] thereafter
Apply with the control panel (Add components → Job → On a schedule) or:
doctl apps update <app-id> --spec ./app.yaml
Prefer the Dashboard Encrypt checkbox (or type: SECRET) for ROVELA_API_KEY. Keep sending_access on production senders — never full_access (Authentication). Never log the API key. After the first submit, the app spec stores an EV[…] ciphertext — submit that encrypted value on later updates rather than pasting plaintext again (app spec envs).
Every-15-minutes ops pulse example:
schedule:
cron: "*/15 * * * *"
time_zone: UTC
One-shot Node.js script (window-scoped Idempotency-Key)
Add a script your Job run_command can execute:
// 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) {
// Replace with your queries against Postgres / Redis / APIs
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,
// never log 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’s Idempotency-Key (up to 256 characters) makes a retry of the same send within 24 hours return the original response instead of mailing again (Send Email API). That matters on App Platform because:
- A Job may be re-triggered while you debug
run_command - Cron boundaries and redeploys can re-enter the same calendar window
- Manual console / one-off invocations can overlap a scheduled fire
Recommended keys:
| Key | Use when |
|---|---|
do-job-daily-digest:{yyyy-mm-dd} | One ops digest per UTC day |
do-job-hourly-ops:{yyyy-mm-dd}T{HH} | Hourly snapshot (0 * * * *) |
do-job-fifteen-ops:{yyyy-mm-dd}T{HH}:{mm} | Every-15-minutes pulse (mm floored to 00/15/30/45) |
do-job-weekly-report:{yyyy}-W{ww} | One founder report per ISO week |
do-job-customer-digest:{userId}:{yyyy-mm-dd} | Per-user digests (one key per recipient window) |
If you mail many personalized digests, loop with a fresh key per recipient (or POST /emails/batch for up to 100 non-template messages — batch rejects template / topic_id by design (Send Email API)). Cap concurrency; on 429, back off and retry with the same key. Keep wall time short so the Job container can exit cleanly.
Python alternative
#!/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)
Set run_command: python bin/send_daily_digest.py (or your image’s interpreter path) on the Job component.
Attach and verify the Job
- Deploy the script with your usual App Platform push /
doctl apps update. - Confirm env vars on the Job:
ROVELA_API_KEYencrypted,MAIL_FROM/DIGEST_TOset, scopeRUN_TIME. - In Settings → Job → Job trigger, choose On a schedule (or confirm
kind: SCHEDULEDin the spec) with cron + time zone. - Watch Activity → Jobs, or list invocations:
curl -s -H "Authorization: Bearer $DIGITALOCEAN_TOKEN" \
"https://api.digitalocean.com/v2/apps/<app-id>/job-invocations"
You should see the Job start, your JSON log line with idempotencyKey, and a completed invocation. Store the Rovela emailId if you correlate against webhooks later.
Failure modes and how to handle them
| Symptom | Likely cause | What to do |
|---|---|---|
Rovela 401 | Bad / missing ROVELA_API_KEY, or used full_access where only send is needed | Reset the encrypted env; confirm sending_access (Authentication) |
Rovela 400 naming a domain | MAIL_FROM domain not verified | Verify the domain under Rovela Domains (Quick Start) |
Rovela 429 | Rate limit | Back off and retry with the same Idempotency-Key |
| Job never fires | Cron denser than 15 minutes, wrong kind, or wrong component | Use ≥ */15; confirm kind: SCHEDULED and Job trigger On a schedule |
| Duplicate digests | Re-run / redeploy in the same window without a stable key | Use window-scoped keys as above; Idempotency-Key collapses duplicates within 24h |
| Job burns instance time | run_command never exits (e.g. started a server) | Exit after send; use a Worker for long-lived processes |
| Secret in build logs | Key scoped BUILD_TIME / RUN_AND_BUILD_TIME or not Encrypt | Use RUN_TIME + Encrypt / type: SECRET |
Production checklist
-
Verified Rovela sending domain;
MAIL_FROMmatches it -
ROVELA_API_KEYissending_access, Encrypt /SECRET,RUN_TIMEonly -
Job
kind: SCHEDULEDwith five-field cron ≥ every 15 minutes and an explicittime_zone -
run_commandexits after send (or after a deliberate empty-window no-op) -
Idempotency-Keyscoped to the schedule window (day / hour / 15-min bucket) - Activity → Jobs (or job-invocations API) shows successful completions
- Team console access limited for encrypted env hygiene
-
Optional: Rovela webhooks for
email.bounced/email.complainedon digest recipients
FAQ
Can I run every 5 minutes?
No — scheduled App Platform Jobs require at least every 15 minutes (manage jobs). Keep the same Rovela helper and pick another scheduler if you need denser ticks.
Should I use Rovela scheduled_at from the Job?
Usually no. If the Job already fires at send time with a live body, call POST /emails immediately. Use scheduled_at when you accept the message now and want Rovela to deliver later without a container awake (Scheduled Email Sending).
Job vs Worker vs Service for digests?
Use a SCHEDULED Job for calendar digests that start → exit. Use a Worker for continuous queue consumers. Use a Service only for HTTP APIs — jobs are not routable (glossary: Job).
What key scope does sending_access cover?
Enough to queue mail via POST /emails / batch. Reading messages, rescheduling, and cancel need full_access or a dashboard session (Send Email API, Authentication). Keep the Job on sending_access.
Does a 200 mean the founder already got the digest?
No — it means Rovela accepted and queued the message. Use GET /emails/:id, events, or webhooks for delivery state (Quick Start).
Ship the schedule; let Rovela deliver
DigitalOcean App Platform Jobs give you first-class five-field cron (with time_zone) on short-lived, non-routable containers. Your run_command owns the live digest and a window-scoped Idempotency-Key. Rovela owns HTTPS accept, DKIM, and delivery. Encrypt the sending key, exit cleanly, and you get transactional digests without standing up SMTP or a custom mail stack.
Next: verify a domain, create a sending_access key, add a kind: SCHEDULED Job with encrypted env vars, deploy the one-shot script, and confirm the first invocation under Activity → Jobs.