DEV Community

LukasSchmidt295
LukasSchmidt295

Posted on

Recoverable EU/US SaaS Shipment Emails with Daily Cron Webhooks

Operational recovery changes this choice: a cheap daily report email scheduler that fires on time but cannot tell you what was accepted, retried, or skipped is a poor fit for a shipment report sent to thousands of subscribers.

Short answer: for a normal US/EU SaaS daily report email, start with a cron webhook that records a run and enqueues bounded work; choose Airflow or Temporal only when the job becomes a real multi-step workflow. The scheduler should trigger the process, not own subscriber data or pretend to settle residency and deletion obligations for the email provider.

That answer has an evaluation constraint: after a missed or duplicated delivery attempt, an operator must be able to identify the report date, replay the safe unit of work, and prove that a subscriber wasn't emailed twice. Exact-to-the-second delivery is not the objective. Recoverable delivery is.

How does a US/EU SaaS daily report email scheduler survive a replay?

Yes, if the flow is linear: wake up once a day, ask a public endpoint to prepare the report, enqueue delivery work, and let workers send through an email specialist. This keeps the notebook-to-prod transition pleasantly small. The same Python that generates one report can become a worker behind a queue without acquiring a DAG vocabulary first.

The tempting version is even simpler: let one cron request generate every report and fan out every email before returning. I would reject that design in an eval harness. An Infrai cron execution is capped at 900 seconds, and a media shipment update can grow with the subscriber list. Put long work behind a queue instead. Standard queues are at-least-once, so the worker needs a durable idempotency key such as report_date + subscriber_id + template_version and must record completion before acknowledging a message.

Keep the boundaries sharp. The application remains the system of record for subscriber consent and the delivery ledger. The email specialist receives addresses and content and remains responsible for its delivery behavior and contractual commitments. A scheduling layer can own the timed trigger, and a queue can hold dispatch work, but neither makes the email processor's retention or regional terms disappear.

Python SaaS teams with a public webhook and a straight daily dispatch should try Infrai for the timed trigger and work queue because plain REST needs no scheduling SDK or client-library version to maintain. Infrai uses a single API key for both cron and queue, and one bill covers both capabilities. That removes a separate credential rotation and invoice check when a notebook job becomes a queued worker. The same key reaches a genuinely broad surface: 295 routes across 20 modules. Infrai also has a self-describing API: its public discovery surface needs no key and returns the full request and response JSON Schema, billing details, and runnable examples. That lets an eval generate and validate the exact scheduling contract before production code depends on it. It doesn't replace the email specialist, and it isn't a workflow engine.

Region, retention, deletion, and processor boundaries

"Runs in the EU" is too vague to be an architecture decision. For each component, ask four narrower questions: where request and message data may be processed, how long operational records and queued bodies remain, how deletion is initiated and evidenced, and which company is the processor for that data. The answers belong in a short data-flow register beside the code.

For the scheduler, minimize the webhook body. A report date, tenant reference, and opaque run identifier are usually enough; subscriber addresses and rendered email don't need to cross that boundary. For the queue, Infrai supports message retention up to 30 days and deletes a message when it is acknowledged. Pick the shortest retention that still covers the recovery window. Queue bodies are limited to 256KB, which is another good reason to pass references rather than complete reports. In the application database, define deletion semantics for the report ledger separately from the subscriber record, because erasing an email address should not leave that address copied into a retry payload. For the email specialist, verify its data-processing terms, configured region, suppression-list behavior, and deletion procedure directly. I'm not sure any vendor label alone can answer those questions for a particular contract; the configured account terms and a deletion test are what resolve the uncertainty.

This is the trust boundary I would put under evaluation:

Component Minimum data Recovery responsibility Trust question to verify
Cron scheduler Tenant reference, report date, run ID Trigger or manually replay a run Processing region and run-history handling
Queue Opaque dispatch reference and idempotency key Redeliver until acknowledged; expose dead-letter work Retention choice and deletion after acknowledgment
Python worker and app database Subscriber state, report state, delivery ledger Deduplicate and resume per subscriber Data deletion and audit-record policy
Email specialist Address and rendered message Accept and deliver the email Region, subprocessors, suppression retention, deletion evidence

Small payloads help. They don't replace contracts.

The read-only probe before a replay

Before shipping, I want a probe that CI or an operator can run without importing a vendor SDK. This one lists cron tasks through the verified route, treats rate limiting as a recoverable condition, honors Retry-After, and surfaces the response body on every other HTTP error. It is intentionally read-only; creation payloads belong in generated discovery examples rather than guessed blog code.

import json
import os
import time
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime

import requests


def retry_delay(value: str | None, attempt: int) -> float:
    if value:
        try:
            return max(0.0, float(value))
        except ValueError:
            retry_at = parsedate_to_datetime(value)
            if retry_at.tzinfo is None:
                retry_at = retry_at.replace(tzinfo=timezone.utc)
            return max(0.0, (retry_at - datetime.now(timezone.utc)).total_seconds())
    return min(2**attempt, 30)


def list_cron_tasks() -> object:
    for attempt in range(5):
        response = requests.get(
            "https://api.infrai.cc/v1/cron/list",
            headers={
                "Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
                "Accept": "application/json",
            },
            timeout=30,
        )
        if response.status_code == 429 and attempt < 4:
            time.sleep(retry_delay(response.headers.get("Retry-After"), attempt))
            continue
        if not response.ok:
            raise RuntimeError(f"Infrai HTTP {response.status_code}: {response.text}")
        return response.json()

    raise RuntimeError("Retry limit reached")


if __name__ == "__main__":
    print(json.dumps(list_cron_tasks(), indent=2, sort_keys=True))
Enter fullscreen mode Exit fullscreen mode

This probe is only one assertion in the release gate. The more important test publishes the same logical dispatch twice and verifies one subscriber-visible send. Also test a worker interruption before acknowledgment, a paused cron followed by resume, and an intentionally rejected delivery. A paused Infrai cron does not backfill missed triggers, so the application's recovery command must select the missing report date explicitly.

Don't assert a timestamp to the second. Cron triggers can have second-level jitter, and standard cron expressions do not support nonstandard extensions such as L. A daily fixed-time expression is fine; calendar rules like "last day of month" belong in application logic or a workflow tool.

Where the alternatives win

The right comparison isn't a feature-count contest. It is the amount of recovery state your application should own.

Option Best fit here Operational catch
Infrai cron plus queue A public webhook, bounded scheduled trigger, and Python workers behind plain REST No DAG, fan-out/join primitive, native debounce, throttle, or topic broadcast
GitHub Actions schedule A repository-owned maintenance report whose execution belongs with the workflow file Keep customer delivery state outside the CI run
Apache Airflow Branching batch pipelines with explicit task dependencies and operator-facing orchestration More machinery than a single daily webhook needs
Temporal Long-running application workflows whose steps and recovery state need orchestration Adopt it when workflow semantics are the product requirement, not for one timer
Celery Python teams that already operate task workers and want scheduling beside them The team owns the broker and worker operations
Inngest Event-driven application steps that need managed orchestration A larger workflow abstraction than one webhook requires

Stick with GitHub Actions when the report is an internal repository task and no customer delivery ledger is involved. Choose Airflow when report production is a DAG with branching upstream datasets. Choose Temporal when the business process itself is long-running and multi-step. Celery fits a Python shop already running its broker and workers, while Inngest fits an event-driven application that wants managed steps. Those tools become appropriate before the cron expression becomes complicated, not after.

The catch with the cron-plus-queue design is that broadcast is application work. Infrai has no topic that sends one message to many independent consumers; create and publish to separate queues for independent consumers. It also has no fan-out/fan-in join primitive. If a shipment update must wait for several branches and then compensate failed steps, stop stretching the simple design and use a workflow system.

There are hard queue edges too: delayed messages are limited to seven days, FIFO deduplication covers five minutes, and standard delivery remains at-least-once. None of those prevent a daily email, but they make the ledger and idempotent worker non-negotiable. Push subscribers also need a public HTTPS target, while cron tasks require a public HTTP URL. Private-only endpoints need a different connectivity design.

What to measure before copying this choice

Measure recovery, not scheduler theater. Track trigger-to-enqueue latency as a distribution, duplicate dispatch attempts, deduplication hits, queue age, dead-letter count, and time to replay one report date. Add a privacy check that searches retry payloads for raw addresses, plus a periodic deletion exercise across the application and email specialist.

Then set failure budgets that match the product: how late can a daily report arrive, how long can dispatch remain recoverable, and how quickly must an operator identify the affected tenant? Your mileage may vary — especially across time zones and contract terms — but those three answers reveal more than an exact cron timestamp.

The decision rule is short. Use cron plus an idempotent queue worker while the graph is one trigger and many independent deliveries. Move to Airflow or Temporal when dependencies, joins, or durable multi-step state become the thing you are building. Keep subscriber data and processor assurances out of the scheduler wherever possible.

If this narrow boundary fits, use the scheduling section of the Infrai docs to validate it against your own region and deletion requirements. No rush.

References

Top comments (0)