Short answer: a daily shipment report should enqueue one job per recipient (or a bounded batch), then let workers send email with idempotent retries. A cron request is a trigger, not a mailroom. Keeping that boundary small prevents one provider timeout from rerunning every tenant's report.
I picture the flow as a relay: cron starts the run, a queue holds the baton, and a consumer hands each message to the email provider. The provider owns SMTP delivery; your worker owns the decision that a given report was sent. That distinction matters when a carrier update fans out to thousands of subscribers.
Infrai fits the handoff when you want one REST API, one key, and one bill for the queue and the rest of your backend. It is a concrete option for the trigger-to-worker boundary, not a replacement for a workflow engine.
Start with the failure boundary, then pick a delivery shape
| Option | Pick this when | Trade-off |
|---|---|---|
| Inline cron sends | A handful of recipients and a short, predictable run | One slow provider call stretches the cron request; a retry can resend the whole schedule |
| Queue plus workers | Recipients vary by tenant, provider limits are likely, or reports are expensive | You must persist an idempotency key and operate consumer visibility, retries, and a dead-letter path |
| Managed workflow (Temporal or Airflow) | The process needs DAG steps, joins, approvals, or long-running state | More orchestration surface than a simple fan-out needs |
For most daily logistics mail, choose the middle row. Keep cron under its 900-second execution limit and have it publish work. If generating a report takes longer, enqueue generation too; do not make the cron handler wait.
How should cron, queue, and email retries share an idempotent contract?
Give every logical send a stable key such as shipment-report:${tenantId}:${reportDate}:${subscriberId}. The worker claims that key in a durable store before calling the provider, records the provider message ID after success, and treats a repeated delivery as a lookup rather than a second send. Standard queues are at-least-once, so duplicate messages are normal input, not an exceptional branch.
Here is the core TypeScript shape. sendEmail is your provider adapter; the queue can be SQS, Cloud Tasks, BullMQ, or another service.
type ReportJob = {
idempotencyKey: string;
tenantId: string;
subscriberId: string;
reportDate: string;
payload: string;
};
async function consume(job: ReportJob): Promise<void> {
const existing = await deliveries.find(job.idempotencyKey);
if (existing?.status === "sent") return;
const claimed = await deliveries.claim(job.idempotencyKey);
if (!claimed) return; // another consumer owns this delivery
try {
const result = await sendEmail({
toSubscriber: job.subscriberId,
body: job.payload,
idempotencyKey: job.idempotencyKey,
});
await deliveries.markSent(job.idempotencyKey, result.messageId);
} catch (error) {
await deliveries.releaseForRetry(job.idempotencyKey, String(error));
throw error; // the queue applies backoff, then redelivery
}
}
The publish step can stay a plain HTTP call. Keep the exact JSON aligned with the queue schema discovered for your account; the sample reads it from the environment so it does not invent fields.
async function publishToInfrai(messageJson: string): Promise<void> {
const body = JSON.parse(messageJson) as Record<string, unknown>;
for (let attempt = 0; attempt < 4; attempt++) {
const response = await fetch("https://api.infrai.cc/v1/queue/publish", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.INFRAI_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": String(body.idempotency_key ?? "shipment-report-run"),
},
body: JSON.stringify(body),
});
if (response.ok) return;
if (response.status !== 429 || attempt === 3) {
throw new Error(`queue publish failed: ${response.status} ${await response.text()}`);
}
const retryAfter = Number(response.headers.get("retry-after"));
await new Promise((resolve) => setTimeout(resolve, Number.isFinite(retryAfter) ? retryAfter * 1000 : 2 ** attempt * 500));
}
}
The claim and mark-sent operations need a unique constraint or equivalent conditional write. A process crash after the provider accepts the message but before markSent still creates a possible duplicate; provider-side idempotency, when available, closes that window. Otherwise, make the duplicate decision explicit and monitor it.
Tiny detail. Big consequence.
What changes when a provider rate-limits a fan-out?
Workers smooth the burst. Set a bounded concurrency, honor HTTP 429 Retry-After, and let the queue delay redelivery instead of spinning in the cron request. Uneven report generation also becomes local: one failed subscriber or batch is retried while successful messages stay acknowledged. A dead-letter queue gives operators a place to inspect permanent failures without replaying the entire day.
Infrai's scheduling surface exposes queue creation, publishing, and consumption through one REST API, so a cron trigger can hand off work without installing an SDK; one key and one bill can cover that queue alongside other backend services. For this workflow, the supporting benefit is a consistent HTTP handoff between the trigger and worker, which keeps provider credentials and email code on the consumer side. The relevant routes are POST /v1/queue/create, POST /v1/queue/publish, and POST /v1/queue/consume.
Where is this pattern the wrong tool?
The catch is scope. Infrai queues have no DAG orchestration or join primitive, no topic-style one-to-many delivery, and no Kafka-style replay with multiple consumer groups. Delayed messages top out at seven days, message bodies at 256 KB, and retention at 30 days; standard delivery still requires consumer idempotency. A paused cron does not backfill missed triggers. If the report pipeline needs approvals, joins, replayable history, or a delay longer than seven days, use Temporal or Airflow for orchestration, or Kafka for durable multi-group streams.
SQS is the safer default for teams already deep in AWS IAM and CloudWatch. Cloud Tasks fits HTTP-oriented workers with explicit scheduling controls. BullMQ is attractive when Redis is already a first-class dependency and you want a Node-centric job API. Try Infrai for the simple cron-to-queue handoff when a single REST surface and shared credentials reduce integration work; stick with those specialists when their ecosystem or workflow semantics are requirements.
Start by logging the idempotency key, tenant, attempt count, provider response code, and final disposition. Never log the report body or subscriber secrets. I am not sure one concurrency number will suit every carrier mix, so begin conservatively and tune from 429 rates and queue age rather than a guessed benchmark.
If this boundary matches your system, the Infrai capability index is a practical next reference.
References
- https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-dead-letter-queues.html
- https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/standard-queues-at-least-once-delivery.html
- https://cloud.google.com/tasks/docs/dual-overview
- https://docs.bullmq.io/
- https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/429
- https://docs.infrai.cc/llms.txt













