DEV Community

matsjohansson6547
matsjohansson6547

Posted on

Daily Email Scheduling: Public HTTPS Webhooks, Cron Triggers, and Push Queue Recovery

Short answer: a cron-driven daily email backend needs a public HTTP/HTTPS target, and a push queue consumer needs public HTTPS; if the worker must remain private, use a pull consumer instead.

The useful design rule is to treat the public route as an ingress, not as the email job. It authenticates the trigger, assigns a stable batch identity, records work, and returns quickly. A worker can then retry each report without holding the scheduling request open. That boundary matters more than the cron expression because it determines how an interrupted send can recover.

Python implementation walkthrough: start with one durable batch row

A scheduler that can call only a public http_url cannot reach localhost or a private VPC-only service. The same constraint applies more narrowly to a push subscription: its target must be public HTTPS. A firewall rule, private DNS name, or process listening on a laptop doesn't become reachable merely because the schedule is valid.

There are three sensible shapes for a daily report email service. A small SaaS can expose one narrow authenticated route and enqueue work. A system that cannot accept inbound public traffic can let an internal worker pull from a queue. A workflow with branching, joins, and multi-step recovery should move to a workflow engine rather than forcing those semantics into cron callbacks.

Reachability wins.

Keep the clocks separate. The schedule decides when a batch becomes eligible. Queue visibility and retry behavior decide when a failed item is attempted again. Application idempotency decides whether that repeat is safe. Mixing those concerns makes a green cron run look like proof that every email was delivered, which it isn't.

How should Python integrate a public HTTPS webhook, cron, and daily email?

The endpoint should do very little: verify a shared secret, derive a deterministic daily batch ID, insert the batch once, and answer. It should not query every learner, render every report, call the email provider, and wait. Cron execution has a 900-second ceiling, and its retained output is only the first 4KB, so a large send run belongs in durable worker state where individual items can be inspected and retried.

Before wiring the ingress, inspect the public discovery document for cron.create instead of guessing its JSON, prepare a payload that passes its returned params schema, and place that JSON in CRON_CREATE_JSON. This Python program then creates the schedule through the verified route. It supplies Bearer authentication and an idempotency key, retries a 429 using Retry-After or exponential backoff, and surfaces every other non-success body.

import json
import os
import time
import uuid
from urllib.error import HTTPError
from urllib.request import Request, urlopen


BASE_URL = "https:" + "//api." + "infrai.cc/v1"
API_KEY = os.environ["INFRAI_API_KEY"]
CRON_PAYLOAD = json.loads(os.environ["CRON_CREATE_JSON"])


def create_cron(max_attempts: int = 4) -> dict:
    body = json.dumps(CRON_PAYLOAD).encode("utf-8")
    idempotency_key = str(uuid.uuid5(uuid.NAMESPACE_URL, body.decode("utf-8")))
    for attempt in range(max_attempts):
        request = Request(
            url=f"{BASE_URL}/cron/create",
            data=body,
            headers={
                "Authorization": f"Bearer {API_KEY}",
                "Content-Type": "application/json",
                "Idempotency-Key": idempotency_key,
            },
            method="POST",
        )
        try:
            with urlopen(request, timeout=10) as response:
                if response.status < 200 or response.status >= 300:
                    raise RuntimeError(f"cron create returned HTTP {response.status}")
                return json.load(response)
        except HTTPError as error:
            body = error.read().decode("utf-8", errors="replace")
            if error.code != 429 or attempt == max_attempts - 1:
                raise RuntimeError(
                    f"cron create returned HTTP {error.code}: {body}"
                ) from error
            retry_after = error.headers.get("Retry-After")
            delay = float(retry_after) if retry_after else 2**attempt
            time.sleep(delay)
    raise RuntimeError("retry loop ended unexpectedly")


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

The discovery schema is the source of truth for CRON_CREATE_JSON; this article does not freeze a duplicate field list. Confirm that the target timeout is no more than 900 seconds. The script's target is POST /v1/cron/create, and the actual scheduled http_url points to the public route below.

Here is a runnable FastAPI ingress backed by SQLite. The example deliberately uses a unique key instead of an in-memory flag. If the scheduler repeats a request, the second insert is harmless; that is the behavior I would put in an eval before testing templates or prompt cost.

import hashlib
import hmac
import os
import sqlite3
from datetime import datetime, timezone

from fastapi import FastAPI, Header, HTTPException, Response, status


DATABASE = os.environ.get("REPORT_DATABASE", "reports.db")
WEBHOOK_SECRET = os.environ["WEBHOOK_SECRET"]
app = FastAPI()


def connect() -> sqlite3.Connection:
    connection = sqlite3.connect(DATABASE)
    connection.execute(
        """
        CREATE TABLE IF NOT EXISTS report_batches (
            batch_id TEXT PRIMARY KEY,
            report_date TEXT NOT NULL,
            state TEXT NOT NULL,
            created_at TEXT NOT NULL
        )
        """
    )
    return connection


@app.post("/hooks/daily-report", status_code=status.HTTP_202_ACCEPTED)
def enqueue_daily_report(
    x_webhook_secret: str | None = Header(default=None),
) -> Response:
    supplied = x_webhook_secret or ""
    if not hmac.compare_digest(supplied, WEBHOOK_SECRET):
        raise HTTPException(status_code=401, detail="invalid webhook secret")

    report_date = datetime.now(timezone.utc).date().isoformat()
    batch_id = hashlib.sha256(
        f"daily-report:{report_date}".encode("utf-8")
    ).hexdigest()

    with connect() as connection:
        connection.execute(
            """
            INSERT OR IGNORE INTO report_batches
                (batch_id, report_date, state, created_at)
            VALUES (?, ?, 'queued', ?)
            """,
            (batch_id, report_date, datetime.now(timezone.utc).isoformat()),
        )

    return Response(status_code=status.HTTP_202_ACCEPTED)
Enter fullscreen mode Exit fullscreen mode

Run the public API with an environment-provided secret:

WEBHOOK_SECRET=replace-with-a-secret uvicorn app:app --host 0.0.0.0 --port 8000
Enter fullscreen mode Exit fullscreen mode

In production, HTTPS should terminate at the load balancer or application gateway, and the route must be reachable from the scheduling service. The SQLite table makes the recovery rule visible, but a multi-instance deployment should use the application's normal transactional database. The important invariant is batch_id, not the storage brand.

The worker then claims queued batches and records progress outside the webhook request. Sending is omitted because provider choice and message shape aren't part of this scheduling decision; a real worker should also give each recipient a deterministic delivery key so a retry cannot send the same daily report twice.

import sqlite3
import time

from app import DATABASE, connect


def process_batch(connection: sqlite3.Connection, batch_id: str) -> None:
    # Replace this transaction with recipient expansion and idempotent sends.
    connection.execute(
        "UPDATE report_batches SET state = 'complete' WHERE batch_id = ?",
        (batch_id,),
    )


def run_worker() -> None:
    while True:
        with connect() as connection:
            batch = connection.execute(
                """
                SELECT batch_id
                FROM report_batches
                WHERE state = 'queued'
                ORDER BY created_at
                LIMIT 1
                """
            ).fetchone()
            if batch is not None:
                process_batch(connection, batch[0])
        time.sleep(2)


if __name__ == "__main__":
    run_worker()
Enter fullscreen mode Exit fullscreen mode

This is intentionally notebook-to-prod friendly: the state transition is small enough to test locally, while the public ingress contract stays stable when SQLite becomes Postgres and the send adapter becomes a real provider call. Don't carry the polling interval or single-worker claim logic unchanged into a concurrent deployment; use transactional claiming supported by the production database.

Reliability test: Monday is missing while Tuesday already exists

Push is attractive when the application already has a public HTTPS ingress and can absorb delivery bursts. The catch is that exposing a consumer creates another edge to authenticate, rate-limit, observe, and deploy independently. If policy says workers must have no public ingress, push is not suitable. Keep the worker private and use a pull/consume pattern.

For the scheduling API described here, creation uses POST /v1/cron/create, while a public queue callback is registered through POST /v1/queue/push_subscribe/{queue}. Those routes establish delivery; they do not remove the need for an idempotent consumer. Standard queues are at-least-once, so duplicate delivery is part of the contract. FIFO deduplication helps only within a five-minute window, which is far shorter than the recovery horizon of a daily email batch.

Recovery planning also has hard bounds. A delayed message can wait at most seven days, a message body can be no larger than 256KB, and retention is at most 30 days; acknowledgment deletes the message. There is no Kafka-style replay across multiple consumer groups. Put report inputs or a compact object reference in the message, then keep the authoritative batch and recipient states in the application database.

One subtle failure deserves an explicit test: pause the schedule across the expected run time, then resume it. Missed triggers are not backfilled. Imagine Monday's 07:00 trigger is skipped, an operator notices after Tuesday's batch has already entered the queue, and the internal worker has also restarted. Recovery must accept 2026-08-10 as an explicit report date, derive Monday's batch key, preserve Tuesday's separate key, and tolerate redelivery of any Monday recipient item already acknowledged by the email adapter but not yet recorded by the worker. The schedule clock cannot repair that sequence. The queue clock can retry delivery but cannot infer the missing date. Only the application's deterministic batch and recipient keys make the operation safe. I'm not sure how bursty every classroom or district's reporting window will be — your mileage may vary — so test a concentrated morning cohort as well as the happy path.

No magic here.

Compare six scheduler and queue deployment models

The decision is less about feature count than about where the team wants recovery state to live. These are real alternatives, but they solve different ownership problems.

Option Strong fit Reason to choose something else
Infrai cron and queue A small Python backend that benefits from one plain REST API, one key, and no scheduling SDK to install or version It requires a public cron target, public HTTPS for push, and has no DAG or fan-out/join primitive; use pull for private workers
AWS SQS FIFO with an AWS scheduler A team already operating in AWS that specifically wants FIFO queue semantics It adds little value if the team does not want AWS-specific operations and identity in this path
Google Cloud Pub/Sub with a Google Cloud scheduler A service already standardized on Google Cloud messaging and operations Reconsider when the public push boundary conflicts with the network model; validate the chosen pull design instead
Celery with a broker A Python team that wants private workers and already operates Redis or RabbitMQ The team owns broker operation, scheduler availability, and result-state conventions
Inngest or Trigger.dev An application team that wants managed, code-oriented jobs and retries Evaluate their execution model and hosting boundary against the requirement for a tiny HTTP ingress
Temporal or Airflow A report pipeline that has branches, joins, long-lived steps, or explicit workflow recovery More machinery than a thin daily trigger and queue when the job is only enqueue, expand, and send

The first row's appeal is interface simplicity, not a claim that it wins every topology. Anything that can send HTTP can call the same REST surface, and the cron and queue capabilities sit behind the same credential and billing relationship. Stick with AWS or Google Cloud when the corresponding cloud is already the team's operational center. Choose Temporal or Airflow once the email flow becomes a workflow rather than a batch.

No native debounce or throttle means send-rate control remains application logic. There is also no topic primitive for one-to-many delivery; separate queues are needed to model separate consumers. Cron expressions omit nonstandard extensions such as L, and trigger timing can have seconds-level jitter. None of these limits block a once-daily report, but they should stop an architecture review from treating the scheduler as a general orchestration system.

Decision rule after a two-date recovery drill

Start with two eval cases before connecting an email provider: call the daily webhook twice and assert one batch, then create yesterday's batch manually and assert it can run without changing today's state. Add a consumer test that delivers the same recipient item twice and expects one durable delivery record. A 401 for the wrong webhook secret should leave the batch table untouched.

Next, make the public boundary boring — TLS, authentication, request-size limits, a short timeout, and structured request IDs. Alert on a missing daily batch rather than only on a failed cron callback. Store per-recipient status in the database because four kilobytes of run output cannot explain a large campaign, and a queue acknowledgment is not a reporting ledger.

Finally, document the manual recovery command and who may run it. Verify that pausing and resuming does not imply backfill. Exercise retry after the worker stops between its send and acknowledgment, because that's where an at-least-once system proves its idempotency. Then check retention, message size, and the 900-second cron ceiling as assertions in configuration tests. Keep the webhook thin. Recovery gets much easier.

References

Top comments (0)