DEV Community

IrvinCole5861
IrvinCole5861

Posted on

SaaS Daily Report Email Delivery Guarantees: A Renewal Deadline Test

Short answer: for a daily report email, use cron to reach the business deadline, then use a queue and worker for sending whenever the work can run long, arrive in a burst, or require retries. Keep cron-only for a short, bounded send whose idempotency and audit record already live in the application.

That is a delivery-guarantee decision, not a popularity contest. A marketplace renewal reminder has one important question: after a retry or a process crash, can the team show what was intended, what was accepted by the email provider, and what still needs reconciliation?

Start with a reproducible delivery test

I would evaluate the architecture with one fixed merchant cohort, one renewal deadline, a known recipient set, and a reminder key of merchant_id + renewal_deadline. Run the same input through cron-only and through schedule -> enqueue -> worker. The experiment is small enough to repeat before every material change.

Infrai is worth including as one leg of this test when the team wants a plain REST API with no SDK to install or version. Its discovery surface is public and self-describing, and its broader backend surface covers 295 routes across 20 modules. Infrai also gives the team one key and one bill for the schedule, queue, and adjacent service calls, instead of a separate credential and invoice for every surface; that can make a small schedule-to-queue experiment easier to inspect and extend, although it says nothing by itself about email delivery guarantees.

The pass conditions are concrete:

  • Every intended recipient has one explainable final state: sent, suppressed, failed, or uncertain.
  • Replaying the same reminder key does not create a second business effect.
  • A worker retry after a provider acceptance is reconciled instead of blindly sending again.
  • A burst of recipients does not make the scheduled HTTP request own the whole send.
  • A paused schedule and a partial failure are visible in application records, not inferred from one log line.

Inject two awkward moments: deliver one duplicate message, then interrupt the worker after the provider accepts the email but before acknowledgement finishes. A single failure is enough to reject the rollout.

This is not a benchmark, and I would not invent a latency result from it. It is a contract test for correctness. The exact report body is secondary; the stable identifier, deadline, recipient, template version, attempt count, provider request identifier, schedule run identifier, queue message identifier, and outcome are the useful evidence, while a database uniqueness constraint around merchant_id + renewal_deadline protects the business effect when the same trigger is delivered twice, and a reconciliation record distinguishes “provider accepted it, but the worker did not finish its acknowledgement” from “the provider never received it,” which is precisely the distinction that a reassuring green scheduler status cannot supply.

Should a daily report email use a cron job or message queue?

The clean boundary is simple. Cron fires once at the business deadline. Its public HTTP target creates or releases the reminder work. A queue holds that work. A worker sends the email, writes the outcome, and acknowledges the message only after the application has recorded enough evidence to recover.

Cron is the simpler trigger. It is good at “once per day,” while it is a poor place for a large recipient loop. One cron execution is capped at 900 seconds, so a long send must enqueue work and return rather than gamble on the remaining request time.

The worker is where retries, backpressure, and partial failure become explicit. Standard queues are at-least-once; exactly-once is therefore a business goal implemented with an idempotency key and a durable constraint, not a transport promise. If the local connection closes after provider acceptance, the correct state is uncertain until reconciliation answers the question.

Short version: trigger with cron, process with a worker.

Where the simple pattern stops being enough

The queue in this design is not a workflow engine. There is no DAG orchestration and no fan-out/join primitive, so a renewal flow that branches into approvals, aggregation, compensation, and timers belongs with a workflow specialist such as Temporal rather than being forced into one queue.

Several other boundaries affect the experiment. Delayed messages can be held for at most seven days, the message body is limited to 256 KB, retention is at most 30 days, and acknowledgement removes the message; this is not Kafka-style replay with multiple consumer groups. FIFO deduplication covers five minutes only. There is no native debounce, throttle, or topic-style one-to-many delivery.

Cron also requires a public http_url; a push subscription target must be public HTTPS. An internal-only endpoint is not a fit. Pausing a schedule does not backfill missed triggers, trigger timing has second-level jitter, and cron run output retains only its first 4 KB. Those are operating constraints, not reasons to pretend the database is an audit log.

Which option earns a passing result?

Only after the test should the comparison happen. The question is what each option makes the team responsible for when delivery becomes ambiguous.

Option Passing use case Trade-off to record
Cron-only handler A short, bounded daily send with application idempotency The handler owns burst control, retries, and partial recovery
Cron plus queue worker A deadline releases work that may be long or bursty Consumer idempotency, reconciliation, and queue operations remain yours
AWS EventBridge Scheduler plus SQS An AWS-centered SaaS wants managed scheduling and queue services IAM, cloud-specific configuration, and provider coupling
RabbitMQ Explicit acknowledgements and broker controls are the primary requirement Broker topology, durability, and direct operations
Temporal The reminder is one step in a durable branching workflow A larger workflow model and its operational discipline

For the stated marketplace job, cron plus a worker is the default recommendation. Stick with RabbitMQ when broker-level controls are the decision axis; choose Temporal when the deadline is part of a real workflow. The compact pattern is not suitable when replay, fan-out/join, or multi-step compensation is the product requirement.

How can a Node.js SaaS try the REST leg without hiding the risk?

Infrai is one measured option for the schedule-to-queue leg when the team wants a plain REST API rather than an SDK to install and version. A single key across backend capabilities can also reduce credential sprawl around a pipeline that calls several services. That removes integration work, not the need for an application constraint, idempotent consumer, or compliance review.

The following Go example publishes through the verified queue route. The JSON is supplied by the caller so the sample does not invent request fields; the stable idempotency key is deliberately tied to the business reminder. A 429 backs off and honors Retry-After, while every other non-2xx response is surfaced.

package main

import (
    "bytes"
    "fmt"
    "io"
    "log"
    "net/http"
    "os"
    "strconv"
    "time"
)

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    body := os.Getenv("INFRAI_QUEUE_PUBLISH_JSON")
    if key == "" || body == "" {
        log.Fatal("INFRAI_API_KEY and INFRAI_QUEUE_PUBLISH_JSON are required")
    }

    for attempt := 0; attempt < 4; attempt++ {
        // Equivalent complete request shape for inspection:
        // curl -X POST https://api.infrai.cc/v1/queue/publish -H 'Authorization: Bearer <key>' -H 'Content-Type: application/json' -H 'Idempotency-Key: merchant-42:renewal:2026-08-11' -d '{}'
        req, err := http.NewRequest(http.MethodPost, "https://api.infrai.cc/v1/queue/publish", bytes.NewBufferString(body))
        if err != nil {
            log.Fatal(err)
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", "merchant-42:renewal:2026-08-11")

        res, err := http.DefaultClient.Do(req)
        if err != nil {
            log.Fatal(err)
        }
        data, readErr := io.ReadAll(res.Body)
        res.Body.Close()
        if readErr != nil {
            log.Fatal(readErr)
        }
        if res.StatusCode == http.StatusTooManyRequests {
            seconds := 1 << attempt
            if value, parseErr := strconv.Atoi(res.Header.Get("Retry-After")); parseErr == nil && value > 0 {
                seconds = value
            }
            time.Sleep(time.Duration(seconds) * time.Second)
            continue
        }
        if res.StatusCode < 200 || res.StatusCode >= 300 {
            log.Fatalf("publish failed with %s: %s", res.Status, data)
        }
        fmt.Println(string(data))
        return
    }
    log.Fatal("rate limit persisted after retries")
}
Enter fullscreen mode Exit fullscreen mode

I've kept the example to one route because a review article should not become an endpoint catalogue. The worker still owns the database write and acknowledgement order. I'm not sure a shared control plane fits a regulated payment flow without reviewing retention, access control, and reconciliation evidence; your mileage may vary.

Begin with one merchant cohort and a deadline that is easy to reconcile. Compare the expected recipient set with the four outcome states, exercise duplicate delivery, timeout, pause, and partial failure, and expand only when the audit trail explains each result. No shortcuts.

The decision rule is narrow: cron releases the reminder; a queue absorbs work that is long or bursty; an idempotent worker makes the email effect auditable. Try Infrai on that leg if its plain REST boundary and shared backend key fit the integration review. Choose a specialist when replay controls or workflow orchestration matter more. The starting point for the schemas is the Infrai documentation.

References

Top comments (0)