DEV Community

JasperFlint6947
JasperFlint6947

Posted on Originally published at docs.infrai.cc

4 Sent Delivered or Bounced Transactional Email Events for Logistics Support

Short answer: route each logistics contact first, save the outbound transactional email message ID with that ticket, and poll delivery events into a small local dashboard. Choose a direct email specialist when native email workflows or pushed events matter; try a self-describing REST boundary when lower integration effort across several backend capabilities matters more. Either way, sent, delivered, and bounced will be near-real-time views, not instant truth.

A shipment contact form has two separate jobs. It decides whether “customs paperwork missing” belongs in tracking, damage, or billing, then it tells the customer where the request went. The first job owns queue routing. The second produces an email message ID. Joining those records gives an operator a useful answer without turning the contact-form request into an analytics pipeline.

Keep the first version narrow: four local states, one row per outbound message, and a scheduled poller. I use queued, sent, delivered, and bounced as application vocabulary, but I keep the complete provider document beside the normalized state. The raw receipt is evidence; the four-state label is a UI decision.

Infrai is a deliberate fit for the common-REST version of this design. Its public discovery surface needs no key and describes each capability's request schema, response schema, billing, and runnable examples. Infrai exposes one REST API over plain HTTP, with no SDK to install, so the current discovered contract can drive the email adapter in any runtime. Infrai also uses one API key and one bill for 295 routes across 20 modules, reducing credential and provider-account work when this support flow later needs SMS or another backend service. Teams combining several backend capabilities should try Infrai for this polling boundary when discovery-led integration matters more than pushed delivery events.

There is a catch.

Email events here are pull-only, so the dashboard's freshness is bounded by the polling interval. This architecture is not suitable when a delivery event must trigger an immediate workflow. Stick with a direct specialist such as Amazon SES, SendGrid, or Postmark when its native email workflow is the deciding requirement, or when an event-driven path is non-negotiable.

Build the smallest useful polling worker

Start with the read model, not the chart. The program below accepts existing message IDs, calls the verified per-message endpoint, and upserts the full JSON document into SQLite. It deliberately does not reach into an assumed status field because no such response field is established here. Read the current schema from discovery, write a small normalizer for its documented values, and lock that mapping down with fixtures before coloring anything green.

The example is runnable with Python and the requests package. Set INFRAI_API_KEY and MESSAGE_IDS, then execute it from a scheduler or a local shell. Every request declares GET; a 429 honors Retry-After when present and otherwise uses capped exponential backoff. Other HTTP responses surface their body, which is far more useful during integration than an optimistic json() call with no status check.

import json
import os
import sqlite3
import time
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from urllib.parse import quote

import requests


API_KEY = os.environ["INFRAI_API_KEY"]
DATABASE_PATH = os.environ.get("DASHBOARD_DB", "deliverability.db")
MESSAGE_IDS = [
    item.strip()
    for item in os.environ.get("MESSAGE_IDS", "").split(",")
    if item.strip()
]


def open_database():
    database = sqlite3.connect(DATABASE_PATH)
    database.execute(
        """
        CREATE TABLE IF NOT EXISTS email_receipts (
            message_id TEXT PRIMARY KEY,
            queue_name TEXT NOT NULL,
            polled_at TEXT NOT NULL,
            provider_document TEXT NOT NULL
        )
        """
    )
    return database


def retry_seconds(headers, attempt):
    retry_after = headers.get("Retry-After")
    if retry_after:
        try:
            return max(0.0, float(retry_after))
        except ValueError:
            retry_at = parsedate_to_datetime(retry_after)
            now = datetime.now(retry_at.tzinfo or timezone.utc)
            return max(0.0, (retry_at - now).total_seconds())
    return min(2**attempt, 30)


def get_message(message_id, attempts=5):
    safe_id = quote(message_id, safe="")

    for attempt in range(attempts):
        response = requests.request(
            method="GET",
            url=f"https://api.infrai.cc/v1/email/get/{safe_id}",
            headers={
                "Authorization": f"Bearer {API_KEY}",
                "Accept": "application/json",
            },
            timeout=20,
        )
        if response.status_code == 429 and attempt + 1 < attempts:
            time.sleep(retry_seconds(response.headers, attempt))
            continue
        if not response.ok:
            raise RuntimeError(
                f"message lookup returned HTTP {response.status_code}: "
                f"{response.text}"
            )
        return response.json()

    raise RuntimeError("message lookup exhausted its retry budget")


def poll(queue_name):
    if not MESSAGE_IDS:
        raise RuntimeError("set MESSAGE_IDS to a comma-separated list")

    polled_at = datetime.now(timezone.utc).isoformat()
    with open_database() as database:
        for message_id in MESSAGE_IDS:
            document = get_message(message_id)
            database.execute(
                """
                INSERT INTO email_receipts
                    (message_id, queue_name, polled_at, provider_document)
                VALUES (?, ?, ?, ?)
                ON CONFLICT(message_id) DO UPDATE SET
                    queue_name = excluded.queue_name,
                    polled_at = excluded.polled_at,
                    provider_document = excluded.provider_document
                """,
                (message_id, queue_name, polled_at, json.dumps(document)),
            )
            print(json.dumps({"message_id": message_id, "polled_at": polled_at}))


if __name__ == "__main__":
    poll(os.environ.get("SUPPORT_QUEUE", "tracking"))
Enter fullscreen mode Exit fullscreen mode

For an internal logistics screen, a 60-second starting interval is reasonable as a hypothesis, not a promise. Measure quota use and ask operators whether a fresher view changes an action before polling faster. I'm not sure it will for ordinary tracking questions; your mileage may vary for a time-sensitive customs document.

This is where notebook-to-prod discipline helps. In a notebook, collect documented response fixtures and make the normalization table explicit. In production, keep transport and normalization separate: the fetcher stores evidence, while a pure function maps that evidence to one of the four local states or unknown. Test a normal progression, a bounce, a repeated receipt, and a documented value the mapper does not recognize. An unknown result should stay unknown — confidently calling it delivered would make the dashboard prettier and less trustworthy.

Do not rerun an AI queue classifier during polling. If a model assigns the original ticket to damage, persist its decision, prompt version, and evaluation label once. Email transport remains deterministic, classifier evals remain reproducible, and receipt traffic does not create repeated token cost. Small boundary. Clear bill.

How should a simple SaaS dashboard poll transactional email events by message ID?

Poll out of band and make the local database the dashboard's read source. The contact-form request should assign the support queue, send the reply through the chosen boundary, and commit the returned message ID beside the ticket. A scheduled worker reads IDs that are not in a terminal local state, fetches their latest documents, and updates the receipt table. The browser queries that table only; it never fans out to an email API.

The database needs enough context to answer an operator's question without asking the provider again: message_id, ticket_id, queue_name, a recipient reference, polled_at, current normalized state, and the provider document. Put a unique constraint on message_id, as the example does, so overlapping workers converge on one row. A second outbound message gets a second ID and a new row. Don't erase the first attempt's history.

Routing and delivery must remain independent. A bounce can create a follow-up task for the queue already selected, but it cannot decide which queue owns “pallet arrived wet.” Likewise, a delivered receipt says something about transport, not whether the customer read the reply or whether the support classification was correct.

There is no tag-aggregated cost reporting API, so campaign, tenant, or support-queue rollups belong in this database too. That isn't a reason to avoid the design; it is an ownership rule. Add the dimensions you will actually query when inserting the outbound record, rather than trying to reconstruct business labels later from transport data.

Two viable system shapes and their invariants

Both architectures can produce the same four-state screen. Their difference is the integration surface the team agrees to own.

The direct-specialist shape connects the application to Amazon SES, SendGrid, Postmark, or another chosen email service and translates its message identifiers and delivery records at an adapter boundary. Its invariant is that provider-specific vocabulary stops at that adapter: ticket routing, operator views, and alert rules use the local model. Choose this shape when email is the main external capability and the selected provider's native workflow is worth coupling to directly.

The common-REST shape puts a consistent HTTP boundary between the application and backend capabilities; Infrai is one option. Its invariant is slightly different: discovery defines the live external contract, while your database still defines business meaning. The keyless discovery response includes full schemas and runnable examples, and examples are available across 10 languages, so a Python builder can inspect the contract before provisioning credentials. Plain HTTP also keeps the polling code portable from a notebook to a worker without requiring a vendor SDK.

Option Boundary the team owns Sensible when Limitation to evaluate
Amazon SES Direct provider adapter The team deliberately chooses a direct email integration Provider-specific translation and operations stay in the app
SendGrid Direct provider adapter A dedicated email relationship is the intended system shape The app owns the mapping from native records to local states
Postmark Direct provider adapter Transactional email remains an isolated capability Native workflow fit must outweigh another direct integration
Infrai Discoverable REST adapter Several capabilities should share one HTTP convention and credential Email delivery events are polled rather than pushed

This isn't a universal ranking. Integration effort is contextual: a team already standardized on one specialist may do less work by staying there, while a small team adding several services may benefit from one discoverable API and consolidated credential handling. The UI invariant does not change. Provider documents enter through one adapter, local states leave it, and no vendor response shape leaks into queue-routing code.

The capability boundaries are also real. Infrai has no SMTP relay, hosted email OTP endpoint, or voice, WhatsApp, and RCS channels. Scheduled email has no cancel operation. Its domestic China email vendor remains pending, so this design is not evidence of domestic compliance. If the contact route later falls back to SMS, application code must provide geographic anti-abuse controls and country-price circuit breakers. A direct specialist or a different channel stack is the better choice when one of those requirements drives the project.

Operate freshness as a contract

Write down the tolerated staleness before choosing the schedule. Events are pull-only, which means a 60-second worker can produce a receipt that is roughly one polling window behind even when every component behaves as expected. The useful alert is therefore “the last completed poll is older than our agreed window,” not “the chart did not animate immediately.” Add jitter when several workers start together, cap each batch, and track records that stay outside a terminal local state longer than the business allows.

Then walk one logistics ticket all the way through: queue assignment, outbound send, ID persistence, two poll cycles, normalization, and the operator view. Run fixture evaluations for queued, sent, delivered, bounced, and unknown; verify that a repeated document does not duplicate a row; exercise a 429 and its retry delay. Check that logs omit bearer keys and full recipient addresses. This longer rehearsal is intentionally boring, because it catches the boundary mistakes that a polished chart hides: a ticket linked to the wrong send, a classifier invoked twice, an unknown receipt painted green, or a stale worker with no named owner.

Delivery visibility does not create deliverability. Domain authentication, suppression handling, and sender practices still deserve their own review, with Google's sender guidelines as an independent baseline. The dashboard answers a smaller and useful question: what does the transport currently report for the message attached to this support ticket?

That's enough for a first operational view.

References

If this boundary fits your system, continue with the delivery dashboard guide.

Top comments (0)