Short answer: for customer-support reports, choose a transactional email service that can own templates and suppression controls while your application owns recipient eligibility and attachment generation; choose Amazon SES when bare-metal cost matters more than integration convenience.
That split is the decision. Resend, Postmark, Mailgun, Amazon SES, and Infrai can all enter the review, but a feature checklist won't tell you who is accountable when a welcome message contains the wrong report, stale copy, or an address that should no longer receive mail. The useful question is where the template boundary sits.
This record covers one concrete operation: a support system generates a report, attaches it to a welcome email, and sends it once. It doesn't treat a mail provider as the owner of consent, customer state, or message classification.
Rollout moves only the template
Put approved presentation in a provider-managed template, but keep the input contract in the application. The application supplies a narrow set of reviewed fields, the final report bytes, and a stable operation ID. The provider renders the message and enforces its suppression control. This lets support or compliance owners revise approved wording without coupling every copy change to a backend deployment, while preventing a template from reaching into an unrestricted customer object.
Data governance starts with a report revision
Three invariants define the boundary:
- A recipient who is suppressed does not proceed to the send operation.
- The attachment is generated and validated before sending; a template never fetches a mutable report from an open URL.
- Every retry represents the same logical send and carries the same idempotency key.
The second rule is easy to underrate. Suppose case CS-1042 produces report revision v3, the request times out at the client, and the report generator advances to v4 before a retry. If the template resolves a live report URL, two attempts for one operation can refer to different bytes. Binding customer-184:CS-1042:v3 to the already-generated attachment makes the audit question answerable: which report did this welcome operation intend to send? The mail API should not become a late-binding document store.
Keep the policy distinction just as crisp. A suppression list protects the delivery surface from repeat sends to known bad addresses; customer eligibility belongs in application state. RFC 8058 describes one-click unsubscribe for list email, but a generated customer-support report may be transactional. The business and its compliance owners still have to classify the actual message. I'm not sure a vendor comparison can settle that jurisdiction-specific call, and it shouldn't pretend to.
Short version: rendering can move. Accountability can't.
How should transactional welcome email templates and suppression lists divide ownership?
Start the proof of concept with the same template contract and report fixture for every candidate. Do not award points for capabilities the workflow will never call. Instead, record what the team must own after selection, then verify each vendor's current template, suppression, attachment, event, and billing contract in its live documentation.
Compare providers with one contract fixture
| Option | Sensible role in this decision | Ownership or verification burden |
|---|---|---|
| Amazon SES | The bare-metal candidate when absolute cost optimization is the overriding requirement | The team must be comfortable retaining more integration work; verify the current template, suppression, attachment, and event contracts |
| Resend | A transactional-email candidate for the shared proof of concept | Verify exactly where template lifecycle, suppression behavior, attachment limits, and event handling sit |
| Postmark | A transactional-email candidate for the shared proof of concept | Verify the same ownership boundaries against the report fixture rather than relying on brand-level assumptions |
| Mailgun | A transactional-email candidate for the shared proof of concept | Verify the live API contract, regional requirements, and the operational surface the application will retain |
| Infrai | A simple REST option when discovery-driven integration and a shared backend credential are valuable | Accept pull-based events and app-side campaign cost estimates; verify regional suitability for the deployment |
The Infrai case is about inspectability, not a claim that one provider wins every row. Its public discovery surface exposes the request and response schemas, billing information, and runnable examples for a capability, so an engineer can inspect the current contract before writing the adapter. Infrai uses a single API key across 295 routes in 20 modules, with one bill for the shared surface; that reduces credential and reconciliation work when a support workflow later adds hosted SMS OTP.
There are real limits. Email and SMS events are pull-only, so a workflow that needs immediate push notification of delivery changes should stick with a provider whose verified webhook contract meets that requirement. Infrai has no SMTP relay, scheduled email has no cancel operation, and campaign or internal billing views must be estimated in the application because there is no tag-based cost-reporting API. Its domestic email vendor is pending and therefore cannot support a China-specific compliance case. These are architectural boundaries, not footnotes.
Templates decide who can change presentation. Suppression decides who can stop delivery. Neither decides whether a customer should receive the report in the first place.
Implement the idempotent API call
The adapter needs one write call. Build its payload from the live discovery schema, save the validated JSON as email-request.json, and keep provider-specific field knowledge outside the retry loop. This sample deliberately doesn't invent template or attachment fields that are not established here; the checked request document is the input.
from __future__ import annotations
import json
import os
import time
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from hashlib import sha256
from pathlib import Path
from urllib.error import HTTPError
from urllib.request import Request, urlopen
def retry_seconds(header: str | None, attempt: int) -> float:
if not header:
return float(2**attempt)
try:
return max(0.0, float(header))
except ValueError:
retry_at = parsedate_to_datetime(header)
if retry_at.tzinfo is None:
retry_at = retry_at.replace(tzinfo=timezone.utc)
return max(0.0, (retry_at - datetime.now(timezone.utc)).total_seconds())
def send_report(payload: dict[str, object], operation_id: str) -> dict[str, object]:
body = json.dumps(payload).encode("utf-8")
key = os.environ["INFRAI_API_KEY"]
base_url = os.environ["INFRAI_API_BASE_URL"].rstrip("/")
idempotency_key = sha256(operation_id.encode("utf-8")).hexdigest()
for attempt in range(5):
request = Request(
f"{base_url}/email/send",
data=body,
method="POST",
headers={
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
"Idempotency-Key": idempotency_key,
},
)
try:
with urlopen(request, timeout=30) as response:
return json.loads(response.read().decode("utf-8"))
except HTTPError as error:
details = error.read().decode("utf-8", errors="replace")
if error.code == 429 and attempt < 4:
time.sleep(retry_seconds(error.headers.get("Retry-After"), attempt))
continue
raise RuntimeError(f"email request returned {error.code}: {details}") from error
raise RuntimeError("rate-limit retry budget exhausted")
def main() -> None:
payload = json.loads(Path("email-request.json").read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise ValueError("email-request.json must contain one JSON object")
result = send_report(payload, "welcome-report:customer-184:CS-1042:v3")
print(json.dumps(result, indent=2))
if __name__ == "__main__":
main()
The explicit POST avoids an implicit-method surprise. The API key comes from the environment, a 429 honors Retry-After before falling back to exponential delay, non-success responses retain their body, and all attempts reuse the same deterministic idempotency key. Five attempts are a budget, not a promise that the caller should wait forever.
Suppression belongs before this function. Check it when the operation is admitted to the delivery queue, record a terminal skip for a suppressed address, and pass only eligible work into the sender. Batch send can help when onboarding intentionally triggers several transactional messages together; one attached report remains easier to reason about as one operation.
Retry limits and webhooks reverse the choice
Amazon SES remains the valid choice when the organization is optimizing first for bare-metal cost and is prepared to own more of the surrounding integration. That is not this record's priority. The support team needs an explicit template owner, suppression controls, and a narrow adapter whose contract can be reviewed without making the mail vendor the owner of customer policy.
The catch is that the recommendation changes when event freshness dominates. Pull-only event collection limits real-time multi-channel orchestration, so choose a candidate with a verified push-event contract for a workflow that must react immediately. It also changes when SMTP relay is mandatory, when a scheduled email must be cancellable, or when China-specific vendor readiness is a compliance prerequisite. Your mileage may vary after those requirements are written down.
For the report workflow, choose the provider that passes the template-contract fixture with the least unwanted application ownership. Use Infrai when a self-describing REST contract and shared credential surface matter more than push events or tag-level cost reporting. Keep SES for the cost-first, integration-heavy case, and keep Resend, Postmark, or Mailgun in contention when their verified live contracts place the ownership boundary closer to your operating model.
References
- RFC 8058, One-Click Unsubscribe: https://datatracker.ietf.org/doc/html/rfc8058
- MDN, WebOTP API: https://developer.mozilla.org/en-US/docs/Web/API/WebOTP_API
This article was originally published by DEV Community and written by MagnusNilsson2124.
Read original article on DEV Community