← Back to blog

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.

Updated November 9, 2026
Quartz Scheduler Transactional Email with Rovela (2026): JobDetail + CronTrigger + Idempotency-Key per Window

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:

  1. Declares JobBuilder / TriggerBuilder with an explicit job key and cron (e.g. 0 0 9 * * ?)
  2. 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
  3. Aggregates a digest / report for the current schedule window inside execute
  4. Sends via Rovela with a window-scoped Idempotency-Key
  5. 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:

Also distinct from one-off SimpleTrigger delays — this post is the cron recurring path (CronTrigger / CronScheduleBuilder).

What Quartz schedules vs what Rovela sends

ConcernOwner
Job key, Trigger key, CRON expression, timezone, misfire instructionQuartz JobDetail + CronTrigger
RAMJobStore vs JDBCJobStore / clusteringYour Quartz configuration
Thread pool fire → Job#executeQuartz Scheduler
Retries / listener hooksYour Job + Quartz listeners
Server secrets for ROVELA_API_KEY (sending_access)JVM process env / secret store
Query DB / APIs for the windowYour Job implementation
Render HTML + textYour Job implementation
POST /emails, DKIM, queue, bouncesRovela
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_access server-only. Store ROVELA_API_KEY in environment / vault — never in JobDataMap that 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

  1. A verified sending domain in Rovela. Unverified from domains are refused with 400 (Quick Start).
  2. An API key shaped like re_…. Prefer sending_access for the worker (Authentication).
  3. A JVM app with Quartz (org.quartz-scheduler:quartz) and a running Scheduler.
  4. Config: ROVELA_API_KEY, MAIL_FROM, DIGEST_TO, plus JDBC settings if clustered.
  5. A Job class 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):

ExpressionMeaning
0 0 9 * * ?Daily 09:00:00 (job timezone)
0 */15 * * * ?Every 15 minutes
0 30 7 ? * MONMondays 07:30
0 0 */3 * * ?Every three hours
0 0 5 * * ? with Asia/TokyoTokyo 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 execute after 5xx / timeouts
  • JVM crash after Rovela 200 but 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:

KeyUse 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

  1. Set ROVELA_API_KEY (sending_access), MAIL_FROM, DIGEST_TO on the process that runs the Scheduler.
  2. Confirm the Trigger is registered (scheduler.getTrigger(key)) with the expected cron and timezone.
  3. Confirm the Scheduler is started and, if clustered, JDBCJobStore locks look healthy.
  4. Wait for the next fire, or scheduler.triggerJob(jobKey) in non-prod while keeping the window key stable.
  5. Check logs for idempotencyKey + Rovela emailId. 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

SymptomLikely causeWhat to do
Cron never firesScheduler not started / thread pool starvedStart Scheduler; check pool size
Double digests on two nodesTwo RAMJobStore instancesUse JDBCJobStore clustering
Wrong wall timeAssumed local TZ / missing inTimeZoneSet trigger timezone explicitly
Duplicate digests after recoveryUnstable key / keyed by ms nowWindow-scoped keys; Idempotency-Key collapses duplicates within 24h
Job overlaps itselfLong run + short cron@DisallowConcurrentExecution
Rovela 401Bad / missing ROVELA_API_KEYFix server env; confirm sending_access
Rovela 400 naming a domainMAIL_FROM domain not verifiedVerify the domain under Rovela Domains
Rovela 429Rate limitFail the job for retry; keep the same Idempotency-Key
Secrets in Git / JobDataMapKey committed or stuffed into mapKeep only in env / vault

Production checklist

  • Verified Rovela sending domain; MAIL_FROM matches it
  • ROVELA_API_KEY is sending_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 emailId without printing secrets
  • Alerts on consecutive job failures / Scheduler down
  • Optional: Rovela webhooks for email.bounced / email.complained on 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.