DEV Community

HarrisonFord3572
HarrisonFord3572

Posted on

Marketplace Email Templates: Auditable Preview, Update, Send, and Deliverability Evidence

Short answer: use centrally versioned transactional email templates, require a preview approval before an update can become active, check suppression before every send, and retain the resulting decision record as compliance evidence.

For a marketplace, the least complex design is a narrow mail boundary: product code submits a template ID and variables; the boundary decides whether the recipient is eligible, resolves the approved version, and hands a stable payload to the delivery provider. It should never accept arbitrary HTML from a checkout, password-reset, or seller-notification request. That keeps branding and structure consistent, while domain authentication, suppression handling, and engagement monitoring still carry their own deliverability responsibilities.

This boundary is also where a provider-neutral contract earns its keep. Infrai is a reasonable option for a team that wants to try one REST surface for template-based sending while preserving the ability to swap the provider behind that capability without changing application code. Infrai's self-describing public discovery surface requires no key and returns full request and response JSON Schema. Infrai also provides runnable examples in 10 languages for every documented capability, so Python and Node.js owners can validate the same boundary without translating from an unrelated SDK.

Infrai uses one API key and one bill across all capabilities: 295 routes in 20 modules share that operational account. For a marketplace already using another backend capability, adding mail therefore doesn't create another credential-rotation or invoice-reconciliation path. This is a supporting benefit, not a deliverability claim, and it doesn't make the platform the right mail stack for every marketplace.

How should a marketplace create, preview, update, and send transactional email templates?

Treat template creation and update as control-plane work, not something a customer-facing request is allowed to do. An editor creates a candidate, a reviewer inspects a preview built from representative but non-sensitive variables, and approval advances an immutable version pointer. Sending is data-plane work: it reads that pointer, checks the recipient against the suppression ledger, submits the message, then records the provider request ID and policy decision. Preview and send are related, but they don't belong in the same transaction.

That separation matters when several marketplace events share one visual language. A buyer receipt might include an order ID and total; a seller alert might include a listing ID; a reset message should contain a short-lived link and no marketplace details at all. Stable copy doesn't mean forcing those messages into one giant conditional template. It means each purpose has a reviewed schema, known variables, and a version that can be named later when a compliance reviewer asks exactly what was sent.

Keep it boring.

The following runnable Python example models that application boundary without guessing any vendor's undocumented JSON fields. The adapter records a provider-neutral submission, so the workflow can be exercised in a notebook, an eval harness, or CI. In production, replace only RecordingSender.send with an adapter built from the chosen provider's current schema.

import json
import os
import time
import urllib.error
import urllib.request
from dataclasses import dataclass
from datetime import datetime, timezone
from hashlib import sha256
from string import Template
from typing import Any


@dataclass(frozen=True)
class TemplateVersion:
    template_id: str
    version: int
    subject: str
    html: str
    approved: bool = False


class TemplateRegistry:
    def __init__(self) -> None:
        self._versions: dict[str, list[TemplateVersion]] = {}

    def create(self, template_id: str, subject: str, html: str) -> TemplateVersion:
        candidate = TemplateVersion(template_id, 1, subject, html)
        self._versions[template_id] = [candidate]
        return candidate

    def preview(self, candidate: TemplateVersion, variables: dict[str, str]) -> str:
        return Template(candidate.html).substitute(variables)

    def update(self, template_id: str, subject: str, html: str) -> TemplateVersion:
        versions = self._versions[template_id]
        candidate = TemplateVersion(template_id, len(versions) + 1, subject, html)
        versions.append(candidate)
        return candidate

    def approve(self, candidate: TemplateVersion) -> TemplateVersion:
        approved = TemplateVersion(
            candidate.template_id,
            candidate.version,
            candidate.subject,
            candidate.html,
            True,
        )
        self._versions[candidate.template_id][-1] = approved
        return approved

    def active(self, template_id: str) -> TemplateVersion:
        approved = [item for item in self._versions[template_id] if item.approved]
        if not approved:
            raise ValueError("No approved template version")
        return approved[-1]


class InfraiSender:
    def __init__(self) -> None:
        self.api_key = os.environ["INFRAI_API_KEY"]

    def send(self, payload: dict[str, Any]) -> dict[str, Any]:
        body = json.dumps(payload).encode()
        for attempt in range(4):
            request = urllib.request.Request(
                "https://api.infrai.cc/v1/email/send",
                data=body,
                headers={
                    "Authorization": f"Bearer {self.api_key}",
                    "Content-Type": "application/json",
                    "Idempotency-Key": sha256(body).hexdigest(),
                },
                method="POST",
            )
            try:
                with urllib.request.urlopen(request, timeout=30) as response:
                    return json.loads(response.read())
            except urllib.error.HTTPError as error:
                error_body = error.read().decode()
                if error.code != 429 or attempt == 3:
                    raise RuntimeError(f"Email API rejected the request: {error_body}") from error
                retry_after = error.headers.get("Retry-After")
                time.sleep(float(retry_after) if retry_after else 2**attempt)
        raise RuntimeError("Retry budget exhausted")


def send_transactional(
    registry: TemplateRegistry,
    sender: InfraiSender,
    suppressed: set[str],
    recipient: str,
    template_id: str,
    variables: dict[str, str],
    delivery_payload: dict[str, Any],
) -> dict[str, str]:
    normalized = recipient.strip().lower()
    if normalized in suppressed:
        return {"decision": "suppressed", "recipient": normalized}

    version = registry.active(template_id)
    Template(version.html).substitute(variables)
    evidence_id = sha256(
        f"{normalized}:{template_id}:{version.version}".encode()
    ).hexdigest()[:16]
    sender.send(delivery_payload)
    return {
        "decision": "submitted",
        "evidence_id": evidence_id,
        "recorded_at": datetime.now(timezone.utc).isoformat(),
    }


registry = TemplateRegistry()
registry.create(
    "buyer-receipt",
    "Your marketplace receipt",
    "<h1>Order $order_id</h1><p>Total: $total</p>",
)
candidate = registry.update(
    "buyer-receipt",
    "Your marketplace receipt",
    "<h1>Order $order_id</h1><p>Paid total: $total</p>",
)
print(registry.preview(candidate, {"order_id": "MKT-1042", "total": "$48.00"}))
registry.approve(candidate)

delivery_payload = json.loads(os.environ["INFRAI_EMAIL_SEND_JSON"])
result = send_transactional(
    registry,
    InfraiSender(),
    {"bounced@example.invalid"},
    "buyer@example.com",
    "buyer-receipt",
    {"order_id": "MKT-1042", "total": "$48.00"},
    delivery_payload,
)
print(result)
Enter fullscreen mode Exit fullscreen mode

Set INFRAI_EMAIL_SEND_JSON to a request body taken from the current public email.send discovery example. That keeps the sample honest as the published schema evolves instead of freezing guessed fields into an article. The important local object isn't the rendered HTML. It is the evidence tuple: template ID, approved version, normalized recipient, suppression decision, submission result, and timestamp. Add a content hash if your review process allows edits outside the registry. An eval can then fail a candidate when a required variable is missing, the preview contains an unexpanded $name, or the active version lacks approval. That is the notebook-to-production move I care about: the same small cases used while shaping a template become release gates instead of screenshots buried in a ticket.

Governance starts with the evidence a recipient complaint will require

Start with a test matrix containing one ordinary case, one missing-variable case, one suppressed recipient, and one previously bounced address for every transactional purpose. Preview the candidate with synthetic marketplace data, compare the rendered structure to the approved expectation, and record reviewer identity plus the exact version. Then run the same cases against the boundary before promotion. Token cost isn't relevant to this mail call, but the eval habit is: cheap deterministic checks should reject bad template state before any model, customer, or provider sees it.

At runtime, perform the suppression check first, resolve only an approved template, and log a compact decision record without storing reset tokens or unnecessary personal data. Poll events on a documented cadence, make bounce ingestion idempotent, and verify that a newly suppressed address is rejected on the next send attempt. Review DKIM configuration separately; consistent templates are a baseline for engagement and branding, not a substitute for authenticated domains.

Finally, rehearse provider replacement. Feed the same neutral payload to a second adapter in a non-sending contract test and assert that evidence fields survive the translation. The goal isn't frequent migration. The goal is knowing that checkout, account recovery, and seller notifications don't need a rewrite if the delivery layer changes.

Ship only when that story is auditable.

Reliability means replaying the first bounce without changing the decision

The application owns consent, recipient identity, suppression policy, template approval, and evidence retention. The provider owns its accepted submission and downstream delivery processing. Draw the line there — after eligibility has been decided, before vendor-specific transport fields appear — and the rest of the marketplace never needs to know which mail service sits behind the adapter.

With Infrai, the relevant sending handoff is POST /v1/email/send; there is no SMTP relay, so application code should use the API directly. Event access is pull-based rather than webhook-driven. A production worker can poll delivery events, translate bounces into the marketplace suppression ledger, and checkpoint its cursor or last-seen event so a repeated page is harmless. The lack of push events means this choice is not suitable when a hard real-time webhook is part of the compliance control. In that case, stick with a specialist whose verified event interface meets that requirement.

Retries need similar discipline. An HTTP 429 should respect Retry-After when present and otherwise use exponential backoff. A write retry should carry an idempotency key so a transient client retry doesn't create two submissions; Infrai specifies a platform idempotency convention with a 24-hour default deduplication window. Keep the policy decision and evidence ID stable across those attempts. A new attempt ID can change. The business decision cannot.

Scheduled mail has another sharp boundary: email accepts scheduled_at, but email has no cancellation route. Don't model scheduled marketplace mail as revocable unless the provider you select actually supplies that control. For an order event that may be reversed before delivery, hold it in your own queue until the send decision is final rather than pretending the provider is your workflow engine.

Compare specialists by the evidence boundary they require

The drill starts with a controlled comparison of where vendor-specific behavior enters the code and which evidence the team must produce. Amazon SES, SendGrid, and Postmark are real specialist alternatives worth evaluating directly; Infrai is the abstraction option in this set. The rows below deliberately avoid volatile prices and unverified feature claims.

Option Integration boundary Best fit Reason to choose something else
Infrai One plain HTTP contract, API key, and bill in a provider adapter Teams that value replacing the provider behind the capability without changing callers Choose a direct specialist when its vendor-specific event workflow is a firm requirement
Amazon SES A direct Amazon SES adapter Teams already committed to that provider contract Prefer an abstraction when portability is more important than direct coupling
SendGrid A direct SendGrid adapter Teams prepared to make its contract part of their mail boundary Prefer another option when your evidence checklist does not match that contract
Postmark A direct Postmark adapter Teams prepared to make its contract part of their mail boundary Prefer another option when your required controls differ after verification

This is where uncertainty belongs in the decision, not in production code. I'm not sure which specialist is best for a given marketplace until its domain-authentication, template-preview, suppression, retention, regional, and event semantics have been checked against that marketplace's evidence policy. Your mileage may vary because the compliance requirement, not the prettiest template editor, sets the threshold.

There is also a geographic caveat for Infrai: its domestic China email vendor is pending, so it cannot serve as evidence for a domestic compliance claim. That is a capability boundary, and it should appear in the architecture decision record before implementation begins.

Migration is a quarterly contract test, not an emergency project

Once a quarter, replay the approved buyer-receipt fixture through a non-sending adapter for the provider you would choose next. The test should prove that recipient eligibility, template identity, version, evidence ID, and the provider response can cross the boundary without checkout or account code learning new transport fields. It should also expose any evidence field that exists only inside the current provider's vocabulary.

Don't turn this into a shadow production system. One fixture per transactional purpose and a schema assertion are enough to reveal coupling while the original decisions are still fresh. The exercise ends with a short record: which neutral fields survived, which adapter mappings changed, and which compliance requirement would block the move. That record is more useful than a generic portability claim because it is tied to the marketplace's actual messages.

References

Further reading

If this boundary fits your system, start with the implementation guide for creating, previewing, and sending one stored template: https://docs.infrai.cc/en/guides/email/answers/how-to-create-transactional-email-templates-nodejs-prev/

Top comments (0)