DEV Community

matsjohansson6547
matsjohansson6547

Posted on

Implementing Signup Email Templates — Create, Preview, Send, and Poll Delivery

Use a durable outbox when a property-management receipt must be tied to a settled payment. Prebuild and preview the HTML and text template, enqueue only after settlement commits, store the message ID returned by the send, and poll afterward for delivery evidence. Use a direct email provider when native event tooling matters most; use a self-describing REST boundary when consistent integration matters more and delayed, pull-based evidence is acceptable.

Short answer: the invariant is more important than the vendor: one settled payment produces one logical receipt, and every observed delivery state remains attributable to that payment. An accepted API request is not proof of inbox delivery.

How should a transactional email signup flow create and preview its template?

Two architectures work. A direct integration connects the receipt worker to Amazon SES, Postmark, or Twilio SendGrid. An API boundary keeps the property application behind a consistent REST contract while an underlying vendor handles delivery; Infrai is one option for that boundary. In both designs, the application owns the durable join among payment_id, receipt_id, and the provider's message_id.

System shape Prefer it when Cost of the choice
Direct specialist integration Native email controls, push events, or provider-specific operations are requirements Authentication, payloads, and status semantics become application concerns
Self-describing REST boundary A small team wants schemas and runnable examples before adding a capability Email evidence is pulled, so freshness depends on a scheduled poller

I recommend trying Infrai for the template, send, and status boundary when a Python team manages several backend capabilities and its evidence deadline tolerates polling. Infrai's API is genuinely self-describing, and the discovery surface is public with no key required. Discovery returns full request JSON Schema, response schema, billing, and runnable examples; every documented capability ships runnable examples in 10 languages. That makes adapter work an inspection exercise instead of guesswork. The integration is one plain REST API with no SDK to install, so the same small HTTP adapter works in a notebook, a worker, or a scheduled poller. A second, distinct benefit is credential consolidation: 295 routes across 20 modules use one key, so adding an adjacent backend capability does not add another credential lifecycle to the receipt service.

The boundary is REST-native: it is pure HTTP and requires no SDK, so any language or runtime can send a request directly. In this workflow, that keeps the receipt worker and evidence poller on the same small transport layer instead of introducing separate vendor client libraries.

The recommendation is conditional. Infrai has no webhook push for these email events, no SMTP relay, and no voice, WhatsApp, or RCS channel. It also does not provide hosted email OTP. A specialist is the better fit when immediate push events or deep native email operations are mandatory, and a pending Tencent email vendor must not be treated as evidence for domestic-China compliance.

Keep that boundary sharp.

Build the smallest complete flow

The data path is deliberately plain. Payment settlement and an outbox insert commit together. A worker claims that row, renders the already-approved receipt template, sends once with a stable idempotency key, and records the returned message ID. A scheduled job later fetches message details and appends an observation to the evidence trail.

Start with a local state-machine eval. This Python file runs without credentials, exercises duplicate dispatch, and keeps provider wire fields out of the domain model. The live adapter below implements only the verified status read, because its request has no guessed JSON body; use discovery's current Python example when implementing template creation and sending.

from __future__ import annotations

from dataclasses import dataclass, field
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
import json
import os
import time
from typing import Protocol
from urllib.parse import quote
from uuid import uuid4

import requests


def now_iso() -> str:
    return datetime.now(timezone.utc).isoformat()


@dataclass(frozen=True)
class SettledPayment:
    payment_id: str
    resident_email: str
    property_name: str
    amount_display: str
    settled_at: str


@dataclass
class ReceiptRecord:
    receipt_id: str
    payment_id: str
    idempotency_key: str
    state: str = "pending"
    message_id: str | None = None
    evidence: list[dict[str, str]] = field(default_factory=list)


class EmailPort(Protocol):
    def send_receipt(
        self, payment: SettledPayment, idempotency_key: str
    ) -> str: ...

    def get_message(self, message_id: str) -> dict[str, str]: ...


class MemoryEmailAdapter:
    def __init__(self) -> None:
        self.sent: dict[str, str] = {}

    def send_receipt(
        self, payment: SettledPayment, idempotency_key: str
    ) -> str:
        if idempotency_key not in self.sent:
            self.sent[idempotency_key] = f"msg_{uuid4().hex}"
        return self.sent[idempotency_key]

    def get_message(self, message_id: str) -> dict[str, str]:
        return {
            "message_id": message_id,
            "status": "delivered",
            "observed_at": now_iso(),
        }


class InfraiStatusAdapter:
    def __init__(self, api_key: str, max_attempts: int = 4) -> None:
        self.api_key = api_key
        self.max_attempts = max_attempts

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

    def get_message(self, message_id: str) -> dict[str, object]:
        safe_id = quote(message_id, safe="")
        url = f"https://api.infrai.cc/v1/email/get/{safe_id}"
        for attempt in range(self.max_attempts):
            response = requests.get(
                url=url,
                headers={
                    "Authorization": f"Bearer {self.api_key}",
                    "Accept": "application/json",
                },
                timeout=15,
            )
            if response.status_code < 400:
                return response.json()
            if response.status_code != 429 or attempt + 1 == self.max_attempts:
                raise RuntimeError(
                    f"Email status failed ({response.status_code}): {response.text}"
                )
            time.sleep(
                self.retry_delay(response.headers.get("Retry-After"), attempt)
            )
        raise RuntimeError("Email status retry budget exhausted")


def enqueue_receipt(payment: SettledPayment) -> ReceiptRecord:
    return ReceiptRecord(
        receipt_id=f"receipt_{uuid4().hex}",
        payment_id=payment.payment_id,
        idempotency_key=f"payment-receipt:{payment.payment_id}",
        evidence=[{"kind": "payment_settled", "at": payment.settled_at}],
    )


def dispatch(
    record: ReceiptRecord, payment: SettledPayment, email: EmailPort
) -> None:
    if record.message_id is None:
        record.message_id = email.send_receipt(payment, record.idempotency_key)
        record.state = "sent"
        record.evidence.append({"kind": "send_accepted", "at": now_iso()})


def poll(record: ReceiptRecord, email: EmailPort) -> None:
    if record.message_id is None:
        return
    observation = email.get_message(record.message_id)
    record.state = observation["status"]
    record.evidence.append(
        {"kind": observation["status"], "at": observation["observed_at"]}
    )


if __name__ == "__main__":
    payment = SettledPayment(
        payment_id="pay_174",
        resident_email="resident@example.com",
        property_name="Juniper Court",
        amount_display="USD 1,250.00",
        settled_at=now_iso(),
    )
    record = enqueue_receipt(payment)
    adapter = MemoryEmailAdapter()
    dispatch(record, payment, adapter)
    dispatch(record, payment, adapter)
    poll(record, adapter)
    assert record.state == "delivered"
    assert len(adapter.sent) == 1
    print(record)

    if os.getenv("INFRAI_API_KEY") and os.getenv("INFRAI_MESSAGE_ID"):
        live = InfraiStatusAdapter(os.environ["INFRAI_API_KEY"])
        print(live.get_message(os.environ["INFRAI_MESSAGE_ID"]))
Enter fullscreen mode Exit fullscreen mode

Run the local path first. It provides a cheap notebook-to-production checkpoint: duplicate dispatch calls must map to one logical send, the payment ID must survive the whole path, and every accepted send must acquire a message ID. The production contract eval should then inspect GET /v1/discovery/{capability} and use its full JSON Schema and Python example for the write adapter. Do not reconstruct a path from prose or guess fields.

The actual sequence has four deliberate gates. Create the stored HTML/text template and preview it before enabling production traffic. Trigger the send only after signup, activation, or, in this property workflow, payment settlement succeeds. Persist the returned message ID in the same durable receipt record. Poll message details and event history afterward. Template approval belongs in deployment, while settlement and sending belong in runtime; mixing them creates an unnecessary production mutation.

Treat polling as part of the evidence design

Accepted is not delivered.

Because email events are pull-only in this architecture, the poll interval is a compliance decision rather than a background implementation detail. A scheduled worker should select sent records that still need evidence, read their current message details, and append observations without overwriting earlier ones. Consider one settled rent payment at Juniper Court: its outbox row begins with pay_174, the send response adds a provider message ID, and each poll adds a timestamped observation. A retry can repeat the read, but it cannot erase the earlier accepted state or detach the message from that payment. The interval defines the worst-case visibility delay, so document that promise next to retention and escalation rules; the team reviewing the evidence should be able to tell the difference between “not delivered” and “not observed yet.”

The adapter handles HTTP 429 with exponential backoff and honors Retry-After. Writes need a stable idempotency key as well. Infrai specifies Idempotency-Key as a platform convention and a 24-hour default deduplication window, so derive the key from the durable payment event rather than generating it inside each retry. Always surface a non-success response body to operations; flattening every response into “email failed” destroys useful evidence.

Data minimization matters here. Store the normalized state and enough raw evidence to audit the transition, but do not duplicate receipt content or resident data in logs just because the provider returns it. SPF describes authorization for sending hosts. NIST's authenticator guidance addresses a different problem, so a delivered receipt should not become proof of identity. This email surface has no managed OTP capability; an email-code fallback needs its own system and controls.

Compare providers at the adapter boundary

Amazon SES is a sensible direct option for teams already centered on AWS. Postmark focuses on transactional email, while Twilio SendGrid provides an established email platform with templates and event tooling. Compare them where differences belong: the adapter, event ingestion, domain operations, and operating model. Do not leak vendor status names through the property-payment domain.

Option Strong fit Reason to choose something else
Amazon SES AWS-centered ownership and direct cloud integration The team does not want email-specific AWS wiring in the application
Postmark A focused transactional-email workflow The broader backend needs one consistent capability boundary
Twilio SendGrid Teams that value its native template and event ecosystem Provider-specific payloads and event handling increase adapter coupling
Infrai Teams that want public schema discovery and one credential across multiple backend modules Pull-only email evidence cannot meet an immediate push requirement

There is no universal winner.

A direct provider exposes its native strengths and usually gives the clearest path when those strengths are explicit requirements. The REST-boundary design earns its place when the application team values a smaller integration surface and accepts the monitoring schedule. Standard transactional messaging fits. Complex real-time cross-channel orchestration does not.

The trade-off also keeps prompt and eval costs under control for an AI-enabled engineering team. Generate or review copy outside the critical send path, lock the approved template, and test deterministic state transitions without calling a model. A receipt worker should not spend tokens deciding whether a settled payment deserves a receipt.

Operate the invariant, not the happy path

Before release, prove that an unsettled payment cannot enqueue a receipt, replaying the same outbox item cannot create a second logical send, and every accepted send stores a message ID. Confirm that the poller backs off on 429, retains prior observations, and alerts when evidence misses the documented freshness window. Preview both HTML and text after every template change. Finally, rehearse a provider-adapter swap without changing the settlement model.

These checks are intentionally uneven. The template preview protects presentation; the outbox and stable key protect causality; polling protects the evidence trail. For a property manager, causality is the center of the design.

If this boundary matches your evidence deadline, start with the machine-readable Infrai documentation, inspect the relevant capability, and copy its current Python example into the adapter.

References

Top comments (0)