Why your daily email cron should enqueue jobs, not send them inline

A cron handler that sends every email itself has one failure unit and no idempotency. Publish one message per recipient with a deterministic key instead.

A daily digest that sends four thousand emails inside the cron handler has exactly one unit of work, one timeout, and one outcome. If it dies at recipient 3,200, the platform records a failure — and nothing anywhere knows that 3,200 people already got today’s mail. Run the handler again and they get it twice. Infrai’s cron routes make this concrete rather than theoretical, because the handler’s HTTP status is the only thing the scheduler stores about your send.

Splitting the tick from the sending fixes it, and the fix is smaller than it sounds: the cron handler stops sending and starts publishing one message per recipient, each carrying a key that makes a repeat publish a no-op.

What the scheduler can and can’t tell you

The run record holds status, http_status, duration_ms and the first few kilobytes of your response body. That’s it. A 2xx from your endpoint is recorded as succeeded; a 5xx or a 504 becomes failed with CRON_TASK_5XX or CRON_RUN_TIMEOUT. There’s no per-recipient state, because the scheduler never saw the recipients.

So the question isn’t “did the cron run”. It’s “how much work is at risk inside one HTTP request”.

Send inline in the handlerPublish one message per recipient
Unit that failsthe whole day’s sendone recipient
Retry unitthe whole day’s sendone recipient, up to 3 receives
Duplicate risk on replayevery already-sent addressnone, if the publish key is deterministic
Timeout budgetthe handler’s timeout_seconds, 900 max~200 ms per publish
Where a failure landsyour logs, if you kept anythe dead-letter queue
Rate limitinghand-rolled inside a loopthe worker’s concurrency

The tick

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST "https://api.infrai.cc/v1/cron/create" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "daily-digest-tick",
    "cron_expr": "0 7 * * *",
    "timezone": "UTC",
    "task_type": "http_url",
    "task": "https://app.example.com/internal/digest/plan",
    "payload": { "digest": "daily" },
    "overlap_policy": "skip",
    "timeout_seconds": 120
  }'

A 120-second budget is deliberate. The planner should be a query and a loop of publishes — if it needs more than two minutes, it’s doing work that belongs to the worker.

The handler that publishes instead of sending

The interesting line is idempotency_key. Build it from things that don’t change between attempts — recipient plus the digest date — and a replayed publish collapses onto the first one.

import process from "node:process";
import express from "express";
import pg from "pg";

const KEY = process.env.INFRAI_API_KEY;
const DSN = process.env.DATABASE_URL;
if (!KEY || !DSN) throw new Error("set INFRAI_API_KEY and DATABASE_URL");

const db = new pg.Pool({ connectionString: DSN });
const app = express();
app.use(express.json());

async function publish(payload, idempotencyKey) {
  const res = await fetch("https://api.infrai.cc/v1/queue/publish", {
    method: "POST",
    headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify({ queue: "hubcron-digest", payload, idempotency_key: idempotencyKey }),
  });
  const parsed = await res.json();
  if (parsed.ok !== true) throw new Error(`${parsed.error?.code}: ${parsed.error?.message}`);
  return parsed.data.message_id;
}

app.post("/internal/digest/plan", async (req, res) => {
  const day = new Date().toISOString().slice(0, 10);
  try {
    const { rows } = await db.query(
      "SELECT id, email FROM users WHERE digest_opt_in = true AND deleted_at IS NULL",
    );
    let queued = 0;
    for (const user of rows) {
      await publish({ user_id: user.id, email: user.email, day }, `digest:${user.id}:${day}`);
      queued++;
    }
    res.status(200).json({ queued, day, run_id: req.body?.run_id ?? null });
  } catch (err) {
    console.error("plan failed", err);
    res.status(500).json({ error: String(err.message ?? err) });
  }
});

app.listen(8080);

Note what the handler returns: a count, not a promise. The send hasn’t happened yet, and that’s the point.

Proving the key really deduplicates

Publish the same key twice and compare the message_id you get back:

for attempt in 1 2; do
  curl -sS -X POST "https://api.infrai.cc/v1/queue/publish" \
    -H "Authorization: Bearer ${INFRAI_API_KEY}" \
    -H "Content-Type: application/json" \
    -d '{"queue":"hubcron-digest","payload":{"user_id":"usr_77","day":"2026-07-26"},"idempotency_key":"digest:usr_77:2026-07-26"}'
done

Both calls answer with the identical message id — one message exists, not two.

{
  "ok": true,
  "data": {
    "message_id": "qmsg_AzqLKQXespoT85W2kI5nKpBc",
    "queue": "hubcron-digest",
    "payload": { "user_id": "usr_77", "day": "2026-07-26" },
    "status": "available",
    "delivery_count": 0,
    "published_at": "2026-07-26T01:29:53.395484Z"
  }
}

The catch is that the response gives you no other signal — metadata.idempotent_replay stays false on the second call and the cost line still shows the per-publish rate, so the repeated id is your only evidence. And deduplication_id is not a substitute: on a standard queue two publishes with the same deduplication_id produce two distinct messages, which we confirmed on 2026-07-26.

Count what landed:

curl -sS "https://api.infrai.cc/v1/queue/stats/hubcron-digest" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"

The worker, where sending actually happens

import process from "node:process";

const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("set INFRAI_API_KEY");
const QUEUE = "hubcron-digest";

const call = async (path, body) => {
  const res = await fetch(`https://api.infrai.cc${path}`, {
    method: "POST",
    headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  const parsed = await res.json();
  if (parsed.ok !== true) throw new Error(`${path}: ${parsed.error?.code}`);
  return parsed.data;
};

for (;;) {
  const { items } = await call("/v1/queue/consume", { queue: QUEUE, max_messages: 10, visibility_timeout: 60 });
  if (!items.length) { await new Promise((r) => setTimeout(r, 2000)); continue; }
  for (const msg of items) {
    try {
      await call("/v1/email/send", {
        to: msg.payload.email,
        subject: "Your daily digest",
        html: "<p>Today's summary.</p>",
        idempotency_key: `digest-mail:${msg.payload.user_id}:${msg.payload.day}`,
      });
      await call("/v1/queue/ack", { queue: QUEUE, message_id: msg.message_id });
    } catch (err) {
      console.error(msg.message_id, err.message);
      await call("/v1/queue/nack", { queue: QUEUE, message_id: msg.message_id, requeue: true });
    }
  }
}

email.send takes its own idempotency_key, so the belt-and-braces version survives even a redelivery that slips past the queue.

Limits, and when a queue is the wrong answer

max_messages caps at 10 per consume and max_receive_count is fixed at 3, so “retry this recipient eleven times over two days” isn’t something the queue will do for you — that needs a due-time column in your own database and a sweep. Publishing is the only metered step in the chain, which is a rounding error at digest volumes but not free.

If your daily email goes to forty people, all of this is overhead and you should stick with sending inline, guarded by a sent_on column. If you want the fan-out expressed as durable workflow steps with per-step retry policies and a UI that shows where each one stopped, Inngest or Trigger.dev is built for that and a cron trigger plus a queue is a poorer imitation. What you get here instead is that the tick, the queue, the send and the failure record are one account and one bill — the second question doesn’t need a second vendor.

References

Browse more cron developer guides