DEV Community

MiloHastings5316
MiloHastings5316

Posted on

Implementing FastAPI SaaS Receipts With Custom Sending Domains DKIM and Suppression

A SaaS welcome email deliverability checklist changes when the message is a healthtech receipt triggered by payment settlement. That receipt must be reproducible after money moves, even if copy, branding, or the delivery provider changes later. The email is an output of a durable business event; it isn't the event itself.

Short answer: keep the canonical receipt template and its version in the application, render an immutable message from the settled order, authenticate a dedicated custom sending domain with DKIM and SPF, and check one shared suppression list immediately before every delivery attempt.

That choice favors auditability over a marketer-friendly visual editor. It also gives the team one place to answer the awkward question that eventually arrives: exactly which text and totals did order ord_7F31 produce? For health data, keep clinical detail out of the message and link to an authenticated portal; a receipt should disclose no more than the workflow requires.

What should a SaaS welcome email deliverability checklist verify for custom domains and DKIM?

Identity first.

The visible From domain, the domain used for DKIM signing, and the operational owner of DNS changes must be recorded for each tenant. SPF publishes which hosts may use a domain in the SMTP envelope, while DKIM attaches a signature that a receiver can validate against DNS. DMARC then expresses policy and alignment using those authenticated identifiers. These mechanisms are related, but none substitutes for the others.

Keep them separate.

For a shared healthtech SaaS, I would use a dedicated sending subdomain such as receipts.customer.example rather than the organizational root. The reason isn't cosmetic: a subdomain creates a clean ownership and change boundary between transactional mail and employee mail. Your mileage may vary when a tenant's security team already centralizes every DNS record, so document who can rotate keys, what evidence proves verification, and how expiration is detected before enabling production traffic.

A useful release gate is compact:

  • The settled-payment event has a stable order ID and a deduplication key.
  • The custom domain has verified SPF and DKIM records, and DMARC alignment is tested against the actual message path.
  • The application records the template version, recipient, subject, and rendered-content digest with the order event.
  • The recipient is checked against account, tenant, and global suppression scopes at send time.
  • Bounce and complaint events can add a normalized address to that suppression state.
  • Logs contain opaque IDs and delivery state, not receipt HTML or health information.

Don't treat a DNS lookup made during deployment as permanent proof. DNS and signing keys change independently of application code — an especially unpleasant failure boundary because a perfectly healthy worker can continue accepting jobs while authentication quality has changed. Periodic verification should alert on missing or unexpected records, but deployment must not rewrite tenant DNS automatically.

Put template ownership beside the order record

There are three plausible owners for receipt templates: application source, a provider-side template store, or a customer-facing editor backed by your own versioned store. For a payment receipt, application ownership is the conservative default because the message has legal and accounting significance, its variables come from a settled snapshot, and a normal code review can show when wording changed. Store a template version rather than relying on the current file forever.

Provider-side templates are convenient when non-engineers change welcome campaigns frequently. Amazon SES, Twilio SendGrid, and Postmark all expose their own template concepts and APIs; those objects and identifiers are provider-specific, so moving the rendering boundary later requires explicit migration work. That is a portability observation, not a ranking. A customer editor can be appropriate for tenant branding, but only if published versions become immutable and untrusted template input cannot access arbitrary order fields.

Template owner Strong fit Main limit Evidence to retain
Application repository Receipts with reviewed, stable wording Copy changes follow engineering release controls Commit, template version, content digest
Provider template store High-frequency lifecycle copy edits Rendering and identifiers couple the workflow to one API Provider template ID and published version
Versioned tenant editor Contractual tenant-specific branding Requires authorization, escaping, preview, and publication controls Tenant, publisher, schema, immutable version

The catch is real: application-owned templates are not suitable when a lifecycle team must run same-day experiments without deployments. In that case, keep welcome-email content in a governed editor and keep receipts in code. Conversely, stick with a provider template store when its editing workflow is already a deliberate dependency and migration portability is less important than delegated copy ownership. One ownership model does not need to cover both message classes.

Keep the data contract narrow. A receipt template needs display-safe identifiers, currency, settled totals, a support address, and a portal URL; it does not need the entire patient, order, or payment object. This is the same discipline used at a storage boundary: serialize the minimum stable schema, version it, and assume anything persisted may need to be explained years after the current service topology is gone.

Enforce the boundary in Python

The following Python module is intentionally small, but it makes the important states explicit. It renders only after settlement, normalizes the address used for suppression, escapes tenant-controlled display text, and derives an idempotency key from the order plus template version. It uses the standard library, so the example can run without an SDK.

from __future__ import annotations

from dataclasses import dataclass
from decimal import Decimal
from email.message import EmailMessage
from hashlib import sha256
from html import escape


@dataclass(frozen=True)
class SettledOrder:
    order_id: str
    recipient: str
    tenant_name: str
    total: Decimal
    currency: str
    portal_url: str
    payment_state: str


def normalize_address(address: str) -> str:
    local, separator, domain = address.strip().rpartition("@")
    if not separator or not local or not domain:
        raise ValueError("recipient must contain a local part and domain")
    return f"{local}@{domain.lower()}"


def render_receipt(order: SettledOrder, template_version: str) -> tuple[EmailMessage, str]:
    if order.payment_state != "settled":
        raise ValueError("receipt delivery requires a settled payment")

    recipient = normalize_address(order.recipient)
    safe_tenant = escape(order.tenant_name)
    safe_portal_url = escape(order.portal_url, quote=True)
    amount = f"{order.total:.2f} {order.currency}"

    message = EmailMessage()
    message["To"] = recipient
    message["From"] = "Receipts <receipts@mailer.example>"
    message["Subject"] = f"Receipt for order {order.order_id}"
    message.set_content(
        f"Your payment of {amount} settled. View your receipt: {order.portal_url}"
    )
    message.add_alternative(
        f"<p>Your payment of {escape(amount)} to {safe_tenant} settled.</p>"
        f"<p><a href=\"{safe_portal_url}\">View your receipt</a></p>",
        subtype="html",
    )

    idempotency_key = sha256(
        f"receipt:{order.order_id}:{template_version}".encode()
    ).hexdigest()
    return message, idempotency_key
Enter fullscreen mode Exit fullscreen mode

Timing matters.

Rendering is only half of the transaction. The send boundary must consult suppression state at the latest responsible moment, because an address can bounce or complain after a job was queued. A single boolean is usually too weak: retain scope, reason, source event, and timestamp so that account-level opt-out policy is not confused with an address that cannot receive mail. Transactional eligibility and marketing consent are separate policy decisions, even when both ultimately prevent a send.

from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Callable, Collection


@dataclass(frozen=True)
class Suppression:
    address: str
    scope: str
    reason: str
    recorded_at: datetime


def delivery_decision(
    recipient: str,
    suppressions: Collection[Suppression],
    already_sent: Collection[str],
    idempotency_key: str,
) -> str:
    normalized = normalize_address(recipient)
    if idempotency_key in already_sent:
        return "duplicate"
    if any(item.address == normalized for item in suppressions):
        return "suppressed"
    return "eligible"


def deliver_once(
    message: EmailMessage,
    idempotency_key: str,
    suppressions: Collection[Suppression],
    already_sent: Collection[str],
    sender: Callable[[EmailMessage, str], str],
) -> str:
    decision = delivery_decision(
        str(message["To"]), suppressions, already_sent, idempotency_key
    )
    if decision != "eligible":
        return decision
    return sender(message, idempotency_key)


example_suppression = Suppression(
    address="patient@example.net",
    scope="global",
    reason="hard_bounce",
    recorded_at=datetime.now(timezone.utc),
)
Enter fullscreen mode Exit fullscreen mode

There is a subtle race here. Two workers can both observe eligible before either records success. Production code therefore needs an atomic uniqueness constraint on the idempotency key in the outbox or delivery-attempt table; an in-memory collection only demonstrates the decision contract. I am not sure which database isolation level is right for your stack without seeing its queue semantics, but the invariant is testable: for one order and template version, at most one delivery attempt may acquire the send lease.

Test the ugly paths.

Use fixtures for mixed-case domains, malformed addresses, a suppression arriving after enqueue, duplicate settlement events, a tenant-template version changing between enqueue and render, and currency rounding at the boundary. A 550 permanent SMTP response should enter the bounce-processing path according to the receiver and provider event semantics; a transient response should follow a bounded retry policy. Never infer success from the worker finishing without an exception.

Compare delivery systems at the replaceable edge

Once rendering, suppression policy, and idempotency belong to the application boundary, the delivery system has a smaller job: accept a fully formed message, preserve the chosen sending identity, return a correlation identifier, and emit authenticated delivery events. Compare systems on those contracts. Price can matter, but it cannot repair weak domain ownership or an unauditable template mutation. The practical evaluation questions are less glamorous than a feature grid. Can each tenant use its own authenticated domain without sharing signing control? Can DKIM keys rotate without changing application templates? Are bounce and complaint events signed, replayable, and mappable to the original idempotency key? Does suppression happen globally, per tenant, or both? Can raw message content be excluded from routine logs? What retention controls apply to event payloads? Avoid pretending delivery is exactly-once. The useful guarantee is narrower: the application has one durable intent, attempts are idempotently leased, ambiguous outcomes are reconciled using provider events, and a repeated webhook cannot mutate state twice. Email receivers make the final inbox decision, so authentication is necessary but does not guarantee placement. No checklist can honestly promise otherwise. For a healthtech workload, the vendor review must also cover contracts, access controls, retention, incident notification, and the exact data fields crossing the boundary. HIPAA's Security Rule establishes administrative, physical, and technical safeguards for electronic protected health information; it does not turn a generic deliverability feature into a compliance conclusion. Legal and security owners must decide whether a particular message and service relationship are in scope.

Roll out without changing the receipt contract

Begin in shadow mode: render the new version from real settled-event schemas, discard the output, and compare only redacted structural facts such as subject presence, required variable coverage, and content digest stability. Then enable internal recipients, one tenant-owned test domain, and a small production cohort. Promotion criteria should include authentication alignment, suppression decisions, duplicate-attempt counts, event reconciliation lag, and template-version traceability.

Keep rollback boring. Switch the delivery adapter or active template version, but never rewrite the settled order snapshot and never erase the audit record for an attempted message. If the team later moves from application rendering to a governed editor, migrate immutable template versions first, run deterministic rendering comparisons, and preserve old renderers for historical reconstruction.

This design is deliberately conservative. It is not suitable for a marketing organization whose primary need is rapid visual experimentation across dozens of welcome-email variants; use a governed lifecycle-content system for that workload. For payment receipts, template ownership beside the durable order record makes the more important properties — reproducibility, bounded data exposure, and replaceable delivery — straightforward to inspect.

References

Top comments (0)