Quartz Scheduler Transactional Email with Rovela (2026): JobDetail + CronTrigger + Idempotency-Key per Window
Wire Java Quartz JobDetail and CronTrigger to Rovela POST /emails with server-only sending_access and a window-scoped Idempotency-Key for digests and ops reports.
Quartz owns the schedule — Rovela delivers the mail
Quartz is the classic job-scheduling library for the JVM. You declare a JobDetail (what to run) and one or more **Trigger**s (when to run) — typically a CronTrigger built with CronScheduleBuilder. A Scheduler thread pool fires due jobs; your Job#execute method runs with a JobExecutionContext you can use for keys, data maps, and fire times (Quartz documentation).
That shape fits digests, ops reports, trial-expiry reminders, and other transactional messages whose body depends on live app or database state at fire time — signup counts, unpaid invoices, failed jobs — rather than content you can freeze hours earlier with Rovela scheduled_at.
Rovela queues, DKIM-signs, and delivers when your Quartz Job calls POST https://api.rovela.dev/emails (Send Email API, Quick Start, Authentication). Quartz owns the cadence and “is due?” logic. Your job owns aggregation, secrets for sending_access, and a stable Idempotency-Key per schedule window. Rovela only delivers.
This guide wires Quartz CronTrigger → Job → Rovela that:
- Declares
JobBuilder/TriggerBuilderwith an explicit job key and cron (e.g.0 0 9 * * ?) - Keeps
ROVELA_API_KEY(sending_access),MAIL_FROM, JDBC/store settings, and recipients in server-only config — never in browsers, SPA bundles, or a public Git tree - Aggregates a digest / report for the current schedule window inside
execute - Sends via Rovela with a window-scoped
Idempotency-Key - Survives misfires, retries, and clustered schedulers without duplicate mail
Rovela does not create Quartz jobs, JDBCJobStore tables, or Scheduler threads. Quartz owns schedule → fire → execute. Your Job owns query → map fields → POST /emails. Rovela owns delivery.
Distinct from:
- Spring @Scheduled Email — Spring
@Scheduledannotation cron (often on top of a simpler TaskScheduler — distinct from full Quartz JobDetail wiring) - Spring Boot OTP Email API — one-shot OTP / verification sends, not recurring cron digests
- node-cron Transactional Email — Node
node-cronin-process schedule - NestJS Schedule Cron Email — NestJS
@nestjs/schedule@Cron - Laravel Task Scheduler Email — Laravel
Schedule::/ Artisan schedule - APScheduler Cron Email — Python APScheduler CronTrigger
- Resque-scheduler Email — Ruby Resque-scheduler
- RQ Scheduler Email — Python RQ Scheduler
- delayed_job Recurring Email — Ruby delayed_job recurring pattern
- Solid Queue Recurring Email — Rails Solid Queue recurring.yml
- Hangfire Recurring Jobs Email — .NET Hangfire
RecurringJob.AddOrUpdate - Sidekiq-Cron Transactional Email — Ruby Sidekiq-Cron
schedule.yml/Job.create - Celery Beat Transactional Email — Python Celery
beat_schedule/crontab - Agenda.js Scheduled Jobs Email — Mongo-backed
agendadefine/every(Node) - BullMQ Repeatable / Cron Jobs Email — Redis BullMQ
upsertJobScheduler - Temporal Schedule Email — Temporal Schedule Spec + Activity
- Trigger.dev Cron / Scheduled Tasks Email — Trigger.dev
schedules.task - Inngest Cron Transactional Email — Inngest
crontrigger +step.run - Upstash QStash Schedules Email — QStash HTTPS schedule
- Supabase pg_cron Email — Postgres
pg_cron+pg_net - Kubernetes CronJob Email —
batch/v1CronJob → Job Pod - Vercel Cron Digest Email — platform cron
GETto a production route - Scheduled Email Sending — Rovela
scheduled_atwhen the body is already known
Also distinct from one-off SimpleTrigger delays — this post is the cron recurring path (CronTrigger / CronScheduleBuilder).
What Quartz schedules vs what Rovela sends
| Concern | Owner |
|---|---|
| Job key, Trigger key, CRON expression, timezone, misfire instruction | Quartz JobDetail + CronTrigger |
| RAMJobStore vs JDBCJobStore / clustering | Your Quartz configuration |
Thread pool fire → Job#execute | Quartz Scheduler |
| Retries / listener hooks | Your Job + Quartz listeners |
Server secrets for ROVELA_API_KEY (sending_access) | JVM process env / secret store |
| Query DB / APIs for the window | Your Job implementation |
| Render HTML + text | Your Job implementation |
POST /emails, DKIM, queue, bounces | Rovela |
Idempotency-Key = quartz-daily-digest:{yyyy-mm-dd} | Your Job |
A 200 from Rovela means accepted and queued, not “already in the inbox” (Quick Start).
Quartz constraints that shape the email job
- A Scheduler must stay up. Cron evaluation and job execution need a running
Scheduler(embedded in your app, or a dedicated worker process). - Use explicit
JobKey/TriggerKey. Collision-prone anonymous jobs make ops painful in a cluster. - Quartz cron is typically six or seven fields (
seconds minutes hours day-of-month month day-of-week [year]) — note the leading seconds field, unlike five-field Unix crontab. - Misfire instructions matter. If the JVM was down at 09:00, decide whether to fire once on recovery or skip (
withMisfireHandlingInstructionDoNothing/FireAndProceed). - Cluster with JDBCJobStore when multiple nodes share schedules; otherwise two RAM schedulers can double-fire.
- Keep
sending_accessserver-only. StoreROVELA_API_KEYin environment / vault — never inJobDataMapthat lands in a shared store unless encrypted, and never in Git (Authentication). - Prefer HTTPS
POST /emails. Outbound SMTP from a worker is a poor fit (Email API vs SMTP).
When to use Quartz + POST /emails (not scheduled_at)
Use Quartz cron → live send when:
- The HTML must include fresh metrics at fire time
- You already run Quartz (or Spring + Quartz) on the JVM and want schedules next to existing jobs
- You need cron-like digests with misfire / clustering control
- 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 Scheduler (Scheduled Email Sending).
Prefer Spring @Scheduled when you want annotation cron on a Spring bean without full JobDetail/Trigger APIs (Spring @Scheduled Email). Prefer Hangfire on .NET (Hangfire).
Prerequisites
- A verified sending domain in Rovela. Unverified
fromdomains are refused with400(Quick Start). - An API key shaped like
re_…. Prefersending_accessfor the worker (Authentication). - A JVM app with Quartz (
org.quartz-scheduler:quartz) and a runningScheduler. - Config:
ROVELA_API_KEY,MAIL_FROM,DIGEST_TO, plus JDBC settings if clustered. - A
Jobclass Quartz can instantiate (no-arg constructor, or a JobFactory wired to DI).
Register JobDetail + CronTrigger
Maven (versions illustrative — pin what your upgrade guide allows):
<dependency>
<groupId>org.quartz-scheduler</groupId>
<artifactId>quartz</artifactId>
<version>2.3.2</version>
</dependency>
Bootstrap (standalone sketch):
import org.quartz.*;
import org.quartz.impl.StdSchedulerFactory;
public final class DigestSchedulerBootstrap {
public static void main(String[] args) throws Exception {
Scheduler scheduler = StdSchedulerFactory.getDefaultScheduler();
JobDetail job = JobBuilder.newJob(DailyDigestEmailJob.class)
.withIdentity("daily-digest-email", "mailers")
.usingJobData("kind", "daily-digest")
.build();
// Quartz cron: seconds minutes hours day-of-month month day-of-week
Trigger trigger = TriggerBuilder.newTrigger()
.withIdentity("daily-digest-email-trigger", "mailers")
.withSchedule(CronScheduleBuilder
.cronSchedule("0 0 9 * * ?")
.inTimeZone(java.util.TimeZone.getTimeZone("UTC"))
.withMisfireHandlingInstructionDoNothing())
.build();
scheduler.scheduleJob(job, trigger);
scheduler.start();
}
}
Spring Boot + Quartz style (bean method returning JobDetail / Trigger — wire a SchedulerFactoryBean or Spring Boot Quartz auto-config):
@Bean
public JobDetail dailyDigestJobDetail() {
return JobBuilder.newJob(DailyDigestEmailJob.class)
.withIdentity("daily-digest-email")
.storeDurably()
.build();
}
@Bean
public Trigger dailyDigestTrigger(JobDetail dailyDigestJobDetail) {
return TriggerBuilder.newTrigger()
.forJob(dailyDigestJobDetail)
.withIdentity("daily-digest-email-trigger")
.withSchedule(CronScheduleBuilder.cronSchedule("0 0 9 * * ?")
.inTimeZone(TimeZone.getTimeZone("Asia/Tokyo")))
.build();
}
Job that calls Rovela
import org.quartz.*;
import java.net.URI;
import java.net.http.*;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.Map;
@DisallowConcurrentExecution // one digest at a time per job key
public class DailyDigestEmailJob implements Job {
@Override
public void execute(JobExecutionContext ctx) throws JobExecutionException {
String day = Instant.now().atZone(ZoneOffset.UTC)
.format(DateTimeFormatter.ISO_LOCAL_DATE);
String idempotencyKey = "quartz-daily-digest:" + day;
Digest digest = buildDailyDigest(day);
String[] to = System.getenv("DIGEST_TO").split(",");
// never log ROVELA_API_KEY
System.getLogger("digest").log(System.Logger.Level.INFO,
"quartz digest day={0} key={1}", day, idempotencyKey);
try {
String emailId = sendTransactional(
to, digest.subject(), digest.html(), digest.text(),
idempotencyKey,
Map.of("kind", "quartz-daily-digest", "day", day, "platform", "quartz"));
System.getLogger("digest").log(System.Logger.Level.INFO,
"rovela accepted emailId={0}", emailId);
} catch (Exception e) {
throw new JobExecutionException(e, false); // false = do not immediately refire forever
}
}
private static Digest buildDailyDigest(String day) {
// Replace with JDBC / JPA / API queries
int signups = 12, paid = 3, incidents = 1;
String subject = "Acme daily digest — " + day;
String text = "Signups: " + signups + "\nPaid: " + paid + "\nIncidents: " + incidents;
String html = "<h1>Daily digest — " + day + "</h1><ul>"
+ "<li>Signups: <b>" + signups + "</b></li>"
+ "<li>Paid: <b>" + paid + "</b></li>"
+ "<li>Open incidents: <b>" + incidents + "</b></li></ul>";
return new Digest(subject, html, text);
}
private static String sendTransactional(
String[] to, String subject, String html, String text,
String idempotencyKey, Map<String, String> tags) throws Exception {
String apiKey = System.getenv("ROVELA_API_KEY");
String from = System.getenv("MAIL_FROM");
if (apiKey == null || from == null) throw new IllegalStateException("missing env");
String toJson = "["" + String.join("","", to) + ""]";
String tagsJson = "{"
+ "\"kind\":\"" + tags.get("kind") + "\","
+ "\"day\":\"" + tags.get("day") + "\","
+ "\"platform\":\"" + tags.get("platform") + "\"}";
String payload = "{"
+ "\"from\":\"" + from + "\","
+ "\"to\":" + toJson + ","
+ "\"subject\":" + jsonString(subject) + ","
+ "\"html\":" + jsonString(html) + ","
+ "\"text\":" + jsonString(text) + ","
+ "\"tags\":" + tagsJson + "}";
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("https://api.rovela.dev/emails"))
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.header("Idempotency-Key", idempotencyKey)
.POST(HttpRequest.BodyPublishers.ofString(payload))
.build();
HttpResponse<String> res = HttpClient.newHttpClient()
.send(req, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() / 100 != 2) {
throw new IllegalStateException("Rovela " + res.statusCode() + ": " + res.body());
}
// parse id from JSON in production
return res.body();
}
private static String jsonString(String s) {
return "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"")
.replace("\n", "\\n") + "\"";
}
private record Digest(String subject, String html, String text) {}
}
Every-15-minutes ops pulse (bucket Idempotency-Key to the quarter hour):
// cron: "0 */15 * * * ?"
// key: quartz-ops-pulse:{yyyy-MM-dd'T'HH-mm} with mm floored to 00/15/30/45
Cron examples (Quartz six-field):
| Expression | Meaning |
|---|---|
0 0 9 * * ? | Daily 09:00:00 (job timezone) |
0 */15 * * * ? | Every 15 minutes |
0 30 7 ? * MON | Mondays 07:30 |
0 0 */3 * * ? | Every three hours |
0 0 5 * * ? with Asia/Tokyo | Tokyo morning ops |
Window-scoped Idempotency-Key
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 with Quartz because:
- Job listeners / custom retry can re-enter
executeafter 5xx / timeouts - JVM crash after Rovela
200but before the job is marked complete can redeliver - Cluster failover / misfire recovery can shift wall-clock — key off the business schedule window, not millisecond
now - Multi-tenant jobs need tenant ids in the key
Recommended keys:
| Key | Use when |
|---|---|
quartz-daily-digest:{yyyy-mm-dd} | One ops digest per UTC day |
quartz-ops-pulse:{yyyy-mm-dd}T{HH}-{MM} | /15 or hourly cron (bucket to the window) |
quartz-tokyo-ops:{yyyy-mm-dd} | Local-TZ morning digest |
quartz-weekly-report:{yyyy}-W{ww} | One founder report per ISO week |
quartz-tenant-digest:{tenantId}:{yyyy-mm-dd} | Per-tenant JobDataMap |
If you mail many personalized digests in one job, prefer per-recipient keys (or POST /emails/batch for up to 100 non-template messages — batch rejects template / topic_id by design).
Attach secrets and verify the schedule
- Set
ROVELA_API_KEY(sending_access),MAIL_FROM,DIGEST_TOon the process that runs the Scheduler. - Confirm the Trigger is registered (
scheduler.getTrigger(key)) with the expected cron and timezone. - Confirm the Scheduler is started and, if clustered, JDBCJobStore locks look healthy.
- Wait for the next fire, or
scheduler.triggerJob(jobKey)in non-prod while keeping the window key stable. - Check logs for
idempotencyKey+ RovelaemailId. Optionally correlate later with Rovela webhooks.
Prefer sending_access on production senders — never full_access. Never log API keys. Rotate Rovela by create-then-revoke.
Failure modes and how to handle them
| Symptom | Likely cause | What to do |
|---|---|---|
| Cron never fires | Scheduler not started / thread pool starved | Start Scheduler; check pool size |
| Double digests on two nodes | Two RAMJobStore instances | Use JDBCJobStore clustering |
| Wrong wall time | Assumed local TZ / missing inTimeZone | Set trigger timezone explicitly |
| Duplicate digests after recovery | Unstable key / keyed by ms now | Window-scoped keys; Idempotency-Key collapses duplicates within 24h |
| Job overlaps itself | Long run + short cron | @DisallowConcurrentExecution |
Rovela 401 | Bad / missing ROVELA_API_KEY | Fix server env; confirm sending_access |
Rovela 400 naming a domain | MAIL_FROM domain not verified | Verify the domain under Rovela Domains |
Rovela 429 | Rate limit | Fail the job for retry; keep the same Idempotency-Key |
| Secrets in Git / JobDataMap | Key committed or stuffed into map | Keep only in env / vault |
Production checklist
- Verified Rovela sending domain;
MAIL_FROMmatches it ROVELA_API_KEYissending_access, in Scheduler process env / vault only- Scheduler always running; cluster store configured if multi-node
- Explicit JobKey + TriggerKey + documented cron + timezone + misfire policy
@DisallowConcurrentExecution(or equivalent) when digests must not overlap- Rovela send uses a window-scoped
Idempotency-Key - Logs show successful
emailIdwithout printing secrets - Alerts on consecutive job failures / Scheduler down
- Optional: Rovela webhooks for
email.bounced/email.complainedon digest recipients
FAQ
Can the scheduler call Rovela without a job method? No usefully — the scheduler only wakes your code. Your method must aggregate live data and keep sending_access local. Rovela does not host your scheduler or workers.
Should I use Rovela scheduled_at from the job? Usually no. If the scheduler 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 another wake (Scheduled Email Sending).
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 worker 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).
Why both scheduler retries and Rovela Idempotency-Key? The scheduler retries the job when your code fails. Rovela’s key collapses duplicate sends if the HTTP call succeeds twice in the same window (or times out after Rovela accepted). You want both layers.
Ship the Quartz job; let Rovela deliver
Register JobDetail + CronTrigger with explicit keys and cron, put live aggregation and POST /emails in Job#execute, scope Idempotency-Key to the schedule window (and tenant), and keep sending_access server-only. Quartz wakes on time; Rovela delivers the transactional mail.