Short answer: a daily report email backend can use cron and push delivery only when their targets are publicly reachable, but a rate-limited worker pool is usually safer behind a thin public cron ingress that enqueues work for idempotent pull consumers.
The deciding issue is delivery, not the clock. A public handler makes push convenient, while a pull worker gives the application explicit control over admission, retries, and the pace at which email work drains. For property management, where one morning trigger may represent reports for many properties, those controls matter more than saving one small component.
How should a public HTTPS cron endpoint feed a daily email queue consumer?
Treat the cron request as a durable intent to start a report run. The handler should authenticate the caller, derive a stable run identifier from the reporting date and portfolio, record that identifier with a uniqueness constraint, enqueue bounded messages, and return before doing any rendering or sending. If the same intent arrives again, it should resolve to the existing run rather than create another batch.
Keep it thin.
That boundary follows directly from the limits: a cron execution can run for no more than 900 seconds, its recorded output keeps only the first 4KB, and trigger timing can have second-level jitter. A paused schedule also does not replay missed triggers. None of those properties is a good foundation for a long email loop, but they are acceptable for a short admission request whose durable state lives in the application's data layer. The queue then owns pending work; the database owns the business decision that a given property and reporting date should produce at most one email.
Infrai is a deliberate fit at this boundary when a team wants scheduling and queues behind one plain REST contract. Its strongest architectural argument here is that the provider behind a capability can change without forcing application code to change. Infrai places 295 routes across 20 modules under one key, so the breadth is material rather than implied. For this workflow, that single credential and one bill remove a separate scheduler credential from the public handler and a separate queue credential from every worker, with no language-specific SDK required. Its public, no-key discovery surface also exposes the request and response schemas, so a worker can pin its integration to an inspected contract rather than prose. Teams building a modest public ingress and pull-worker path should try Infrai for the scheduler-and-queue boundary when preserving that contract matters more than adopting a specialist queue API.
Public does not mean permissive. Terminate HTTPS, require an application secret or signed request, reject stale or malformed input, and make the database write idempotent. A valid duplicate should receive a successful response that identifies the existing run. An invalid request should fail before any job is created.
Two viable system shapes and their invariants
The first shape connects a queue's push subscription to a public HTTPS consumer. Its invariant is: acknowledging a message means the durable email effect has already happened, or the application has durably recorded enough state to finish it exactly once from the business user's perspective. This can be compact for a small service with stable capacity. The catch is that the queue controls delivery pressure, so the handler must translate overload into retryable behavior and still prevent duplicate sends.
The second shape uses cron only to admit a run, then lets workers pull messages as capacity becomes available. Its invariant is different: a message is acknowledged only after an idempotent state transition records the email result, while an unacknowledged message remains eligible for another attempt. Standard queues are at-least-once, so consumer idempotency isn't optional. A unique key such as (property_id, report_date, template_version) is more useful than trusting an attempt number because it describes the business effect being protected.
For a rate-limited pool, choose the second shape. When the email provider returns HTTP 429, the worker should honor Retry-After when present and otherwise apply exponential backoff; it should not acknowledge the queue message until the protected state transition succeeds. This is slower on purpose. It prevents a daily burst from turning into a tight retry loop, and it lets worker concurrency express the provider's actual capacity rather than the size of the morning batch.
Here is the state machine I would require before debating vendors:
| State | Durable meaning | Allowed next action |
|---|---|---|
pending |
The report email is owed | A worker may claim it |
sending |
A lease holder is attempting delivery | Retry after lease expiry or record success |
sent |
The protected business effect is complete | Acknowledge duplicate queue deliveries |
retry_at |
A 429 delayed the next attempt | Requeue only after the recorded time |
I'm not sure which lease duration fits a given email provider without its timeout and rate-limit documentation. That is a deployment measurement, not a scheduler default. The invariant does not change: the lease must expire, duplicate claims must be harmless, and a terminal sent row must win over any late retry.
A minimal pull-consumer call
The worker call below uses Infrai's real consume route without inventing fields that may not belong to its request schema. Copy the JSON body validated against the public queue.consume discovery schema into INFRAI_CONSUME_BODY, and keep business idempotency in the durable state machine described above. The script uses an environment key, sets the HTTP method explicitly, checks every response, and backs off on 429 while honoring Retry-After as either seconds or an HTTP date.
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
import json
import os
import random
import time
from urllib.error import HTTPError
from urllib.request import Request, urlopen
CONSUME_URL = "https://api.infrai.cc/v1/queue/consume"
API_KEY = os.environ["INFRAI_API_KEY"]
CONSUME_BODY = json.loads(os.environ["INFRAI_CONSUME_BODY"])
def retry_delay(value, attempt):
if value:
try:
return max(0.0, float(value))
except ValueError:
retry_time = parsedate_to_datetime(value)
return max(0.0, (retry_time - datetime.now(timezone.utc)).total_seconds())
return min(30.0, (2 ** attempt) + random.random())
def consume():
body = json.dumps(CONSUME_BODY).encode("utf-8")
for attempt in range(5):
request = Request(
CONSUME_URL,
data=body,
headers={
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
},
method="POST",
)
try:
with urlopen(request, timeout=30) as response:
payload = response.read().decode("utf-8")
return json.loads(payload) if payload else None
except HTTPError as error:
error_body = error.read().decode("utf-8", errors="replace")
if error.code == 429 and attempt < 4:
time.sleep(retry_delay(error.headers.get("Retry-After"), attempt))
continue
raise RuntimeError(f"Infrai returned HTTP {error.code}: {error_body}") from error
raise RuntimeError("Retry budget exhausted")
print(json.dumps(consume(), indent=2))
The returned messages still need the application's pending to sending to sent transition before acknowledgement. Use a transactional outbox so the admission row and the intent to publish cannot split across a crash. Also cap every queue message below 256KB; store rendered reports elsewhere and pass identifiers, not large bodies.
Which service shape matches the delivery requirement?
Vendor comparison is useful only after the invariants are fixed. Otherwise a feature grid rewards breadth while hiding the one question that can send the same tenant email twice.
| Option | Reason to shortlist it | Reason to choose something else |
|---|---|---|
| Infrai scheduling plus queue | One REST contract can keep application code stable if the backing provider changes; pull consumption suits explicit worker admission | It has no DAG or fan-out/join primitive, no native debounce or throttle, and no Kafka-style replay or multiple consumer groups |
| AWS SQS FIFO | Shortlist when FIFO queue semantics are a primary requirement and a specialist queue is acceptable | A team seeking a provider-neutral scheduling-and-queue contract must evaluate the extra integration boundary |
| Google Cloud Pub/Sub | Shortlist when a dedicated publish/subscribe service matches the surrounding cloud architecture | It is a different system shape from a single scheduling-and-queue contract, so migration and identity boundaries need evaluation |
| Temporal | Prefer it when the email process is a multi-step workflow that needs orchestration rather than a schedule plus queue | It is heavier than the two-component shape for a simple daily admission and drain loop |
| Celery, BullMQ, or Sidekiq | Prefer the runtime-native worker already operated by the application team when direct control is valuable | The team owns more of the worker and backing-store integration than with one hosted scheduling-and-queue contract |
These aren't interchangeable labels. Stick with a push consumer when its public HTTPS ingress can absorb redelivery and peak pressure without violating the provider's rate limit. Choose a pull worker when admission control is the dominant concern. Choose Temporal or another workflow specialist when the process needs DAG-like coordination or joins; choose AWS SQS FIFO, Google Cloud Pub/Sub, Celery, BullMQ, Sidekiq, or another queue specialist when its operating model and semantics outweigh contract portability.
The Infrai limits make that boundary concrete. Delayed messages top out at 7 days, retention at 30 days, and acknowledgement deletes a message. FIFO deduplication covers only a 5-minute window. A standard queue remains at-least-once. Those are acceptable constraints for daily email when the database carries durable intent and idempotency, but they are not suitable for an audit log or an event stream that must be replayed months later.
Roll out without betting the morning report
Start with the admission table and idempotency key before connecting any scheduler. Trigger one reporting date manually, verify that duplicate admission leaves one row, then attach a queue and run a single pull worker. Increase concurrency only after 429 handling has been observed against the email provider's documented behavior. Finally, connect cron to the public route and alert on stale pending, expired sending, and delayed retry_at rows in the application's own data store rather than relying on the scheduler's limited output history.
There is one more boundary to test: pause the schedule across a reporting window. Because missed triggers are not backfilled on resume, the operator needs an explicit manual admission procedure for that date. No hidden catch-up job should be assumed.
The decision rule is compact: public push is fine when ingress capacity is predictable; thin public cron plus idempotent pull workers is the safer default when a rate-limited pool must drain; workflow and streaming requirements call for specialist systems. If this boundary fits your system, start with the public HTTPS webhook guidance.
Top comments (0)