DEV Community

UlyssesDonovan1529
UlyssesDonovan1529

Posted on

How to Test Receipt Templates — Custom-Domain Transactional Email API Ownership

A beginner comparing MailerSend with Amazon SES or another simple transactional email API should not pick the cheapest option for welcome emails until custom-domain authentication and suppression-list ownership are defined. For a healthtech order receipt sent after payment settles, own the template in the application unless the people who revise it need a provider's visual editor and approval workflow.

Short answer: a beginner should render a minimal receipt locally, keep suppression and idempotency decisions in application data, and run the same delivery contract against every candidate API before choosing one.

This is deliberately narrower than a feature-matrix contest. MailerSend, Amazon SES, and Postmark can all sit in the candidate set, but a product name does not answer who reviews wording, who can roll it back, or whether a second payment event can create a duplicate receipt. Those are system questions.

One constraint dominates the experiment: never put clinical details in this receipt. Use an order reference, payment state, amount, and support path; keep the message about the transaction. The exact privacy review depends on the data and organization, so I'm not sure a generic provider checklist can settle it. Your security and compliance owners have to approve the fields and retention path.

Migrate templates before the first send

Compare candidates with one fixture and one pass/fail contract. Don't begin with dashboards. Begin with the artifact your customer receives and the state transitions around it.

The focused fixture below models a settled payment without embedding a diagnosis, appointment reason, or medication name. The local template is intentionally boring. That makes the output reviewable in a pull request and keeps notebook-to-prod experiments honest: the same input should produce the same subject and body before any network call occurs.

No send yet.

from dataclasses import dataclass
from decimal import Decimal
from string import Template


@dataclass(frozen=True)
class Receipt:
    order_ref: str
    amount: Decimal
    currency: str
    support_email: str


SUBJECT = Template("Receipt for order $order_ref")
BODY = Template(
    "Payment settled for order $order_ref.\n"
    "Amount: $currency $amount\n"
    "Questions? Contact $support_email."
)


def render_receipt(receipt: Receipt) -> tuple[str, str]:
    values = {
        "order_ref": receipt.order_ref,
        "amount": f"{receipt.amount:.2f}",
        "currency": receipt.currency,
        "support_email": receipt.support_email,
    }
    return SUBJECT.substitute(values), BODY.substitute(values)


fixture = Receipt(
    order_ref="ORD-48271",
    amount=Decimal("29.00"),
    currency="USD",
    support_email="billing@example-health.test",
)
subject, body = render_receipt(fixture)
assert subject == "Receipt for order ORD-48271"
assert "USD 29.00" in body
assert "diagnosis" not in body.lower()
Enter fullscreen mode Exit fullscreen mode

Consider the migration case before wiring a real transport. Order ORD-48271 settles while the old provider adapter is active, the worker claims its outbox row, and a deployment switches new work to another adapter. The business event must still map to one stable receipt, not one message per adapter. Its order reference, approved template revision, recipient eligibility decision, and idempotency key therefore need to survive outside either provider account. The adapter may return its own message identifier for later correlation, but that identifier cannot become the business key. This dry run exposes the practical cost of template ownership: locally owned copy makes transport replacement straightforward but keeps deployment in the editing path; hosted copy gives authorized non-developers a direct editing surface but turns export, revision mapping, and rollback into migration work. Neither result is automatically better. The team should choose the work it can operate under pressure.

Now score each candidate on evidence your team can reproduce: can the custom domain be authenticated under your DNS change process, can a suppressed address be stopped before submission, can delivery events be correlated to the order reference without placing sensitive data in provider metadata, and can a template revision be reviewed and rolled back? Google requires authentication for mail sent to Gmail accounts and documents additional requirements for bulk senders, so domain authentication belongs in the acceptance test rather than a launch-week chore.

A cheapest-plan comparison misses this work. Pricing may matter after the contract passes, but a low unit rate cannot repair unclear template authority or an absent suppression path.

Can a beginner migrate custom-domain transactional email receipt templates?

The simple approach is to call an email API directly inside the payment callback. It is short. It also couples payment latency to a communications dependency and leaves duplicate handling implicit. The chosen design writes an outbox record after the application accepts the settled-payment transition; a worker then evaluates policy, renders the message, and calls a transport adapter.

Ownership model Copy change path Best fit Main limitation
Application Review and deploy code Engineers own controlled, deterministic releases Non-developers depend on the deployment path
Provider Revise a pinned hosted template Operations or compliance owns frequent copy changes Migration must account for hosted revisions
Hybrid Reconcile local policy with hosted copy Separate teams truly own separate layers Two sources of truth require explicit checks

That boundary matters more than SDK ergonomics. It lets an eval harness exercise the policy without sending mail, while the transport test stays small enough to run against MailerSend, Amazon SES, or Postmark with vendor-specific credentials outside the fixture. No candidate gets special assertions.

from dataclasses import dataclass
from typing import Protocol


class EmailTransport(Protocol):
    def send(
        self, *, recipient: str, subject: str, body: str, idempotency_key: str
    ) -> str:
        ...


@dataclass(frozen=True)
class SendDecision:
    allowed: bool
    reason: str


def decide_send(*, recipient: str, suppressed: set[str], payment_state: str) -> SendDecision:
    normalized = recipient.strip().lower()
    if normalized in suppressed:
        return SendDecision(False, "recipient_suppressed")
    if payment_state != "settled":
        return SendDecision(False, "payment_not_settled")
    return SendDecision(True, "ready")


def send_receipt(
    transport: EmailTransport,
    receipt: Receipt,
    recipient: str,
    payment_state: str,
    suppressed: set[str],
) -> str | None:
    decision = decide_send(
        recipient=recipient, suppressed=suppressed, payment_state=payment_state
    )
    if not decision.allowed:
        return None

    subject, body = render_receipt(receipt)
    return transport.send(
        recipient=recipient,
        subject=subject,
        body=body,
        idempotency_key=f"receipt:{receipt.order_ref}",
    )
Enter fullscreen mode Exit fullscreen mode

Keep the suppression lookup ahead of rendering and transport. A provider-managed suppression list can still be useful, but application policy should know why a message was skipped and should not repeatedly submit an address already marked as ineligible. Feed bounce, complaint, and unsubscribe events into one normalized state model, preserve the raw event separately for audit, and make updates monotonic unless an authorized process explicitly reverses them.

The catch is ownership overhead. A locally owned template is not suitable when operations or compliance staff must edit and approve copy without a code deployment. In that case, keep the template in the chosen provider, store the provider template identifier and revision alongside the send record, and test a pinned revision. A hybrid model can serve both groups, but it creates two sources of truth; use it only when the review workflow justifies the reconciliation work.

Small boundary. Big consequence.

Implement a transport contract in Python

A unit test that checks only for a 2xx-style success result is too shallow. The useful eval set checks the rendered artifact, the decision reason, and the number of transport calls. It should include a normal settled payment, a duplicate event, a suppressed recipient, and an event that arrives before settlement.

This fake transport makes the contract executable without an account or network access. The duplicate guard belongs in durable storage in production; the set below only demonstrates the expected behavior in one process. That distinction is important because pretending an in-memory set is production idempotency would teach the wrong lesson.

This is the exit test.

class FakeTransport:
    def __init__(self) -> None:
        self.sent: list[dict[str, str]] = []
        self.keys: set[str] = set()

    def send(
        self, *, recipient: str, subject: str, body: str, idempotency_key: str
    ) -> str:
        if idempotency_key in self.keys:
            return "duplicate_ignored"
        self.keys.add(idempotency_key)
        self.sent.append(
            {
                "recipient": recipient,
                "subject": subject,
                "body": body,
                "idempotency_key": idempotency_key,
            }
        )
        return "accepted"


transport = FakeTransport()
suppressed = {"opted-out@example.test"}

first = send_receipt(
    transport, fixture, "buyer@example.test", "settled", suppressed
)
second = send_receipt(
    transport, fixture, "buyer@example.test", "settled", suppressed
)
blocked = send_receipt(
    transport, fixture, "opted-out@example.test", "settled", suppressed
)
early = send_receipt(
    transport, fixture, "buyer@example.test", "pending", suppressed
)

assert first == "accepted"
assert second == "duplicate_ignored"
assert blocked is None
assert early is None
assert len(transport.sent) == 1
Enter fullscreen mode Exit fullscreen mode

There is a prompt-cost lesson here even though no model should generate the final receipt. If an AI feature proposes subject lines or copy during drafting, evaluate those suggestions offline, then promote approved text into a versioned deterministic template. Don't pay tokens on every receipt to rediscover approved language, and don't make a model response part of the payment-to-receipt critical path. Generation can help the authoring loop; it should not own the transaction record.

For the provider bake-off, record outcomes rather than impressions. Capture domain-authentication readiness, template revision provenance, suppression-event mapping, duplicate behavior, request latency, accepted-versus-delivered counts, bounce categories, and the time required to trace one order reference across your logs. Use the same seed addresses and controlled test domains for each candidate, and define success before looking at results. Actual inbox placement varies with sender reputation, authentication, content, recipient behavior, and mailbox filtering, so a single message appearing in one inbox is not a benchmark.

Retry receipts without losing revision history

Measure the workflow first: time from an approved copy change to a deployed revision, percentage of sends with a traceable template revision, duplicate attempts stopped by the idempotency boundary, suppression decisions made before transport, and settled orders that never reach an accepted send state. Then inspect delivery outcomes by domain and template revision without logging message content.

Also rehearse recovery. Replaying an outbox item must preserve the same idempotency key. A template rollback must select a known revision. A suppression update must take effect before the next queued attempt. These are crisp assertions, not dashboard decoration.

Stick with provider-owned templates when non-developers own frequent copy changes and the provider's review model matches your controls. Choose application-owned templates when code review, deterministic rendering, and portable transport adapters matter more. If neither side can state who approves a sentence and who can restore the previous version, pause the vendor comparison; the team has not resolved the primary decision.

The same architecture can later emit an SMS notification, but it should be a separate rendering contract. SMS length and segmentation depend on GSM-7 versus UCS-2 encoding, so an email body is not a safe drop-in SMS payload.

Copy this choice only after the eval shows that one ownership model reduces untraceable revisions without slowing the people responsible for copy. The winning transport is the one that passes that team's contract.

References

Top comments (0)