Short answer: keep the welcome-message template and generated report in your application, then choose a transactional email API only after it passes five checks: authenticated custom domains, explicit US/EU compliance evidence, attachment support in its current schema, inspectable bounce events, and suppression controls.
For a media service sending a generated report as an attachment, template ownership is the deciding constraint. Application-owned rendering keeps the report, subject, HTML, text alternative, and release history in one deployable unit; the delivery provider gets a completed message. That boundary makes Resend, Postmark, Amazon SES, and Infrai replaceable candidates rather than places where editorial state quietly accumulates. It doesn't make compliance automatic, and it doesn't make delivery failures disappear.
Decision record: invariants before vendor features
The decision is to render the welcome email in the media application, generate the report once, retain a stable message identifier, and hand an immutable delivery request to the selected API. A retry must reuse the same idempotency key. A successful API response means accepted for processing, not read by a person, so downstream state remains pending until delivery review supplies stronger evidence.
Five invariants drive the design:
- The sending domain is verified and authenticated before production traffic begins.
- The exact report bytes are associated with a stable content hash and message identifier.
- A retry after HTTP 429 cannot create a second welcome email.
- Bounce or complaint review can add the recipient to suppression before another campaign or transactional retry.
- Compliance claims are market-specific evidence, not a logo in a comparison table.
The failure boundaries matter more than the happy path. Report generation can fail before any delivery call; authorization or request validation can fail at the API boundary; acceptance can be followed by a bounce; and a polling worker can lag behind the actual delivery event. Keep those states separate. In particular, don't regenerate an attachment during a transport retry: if the report contains time-sensitive figures, two byte-distinct PDFs under one logical welcome event destroy the audit trail even when only one reaches the inbox.
This is deliberately conservative.
For US/EU delivery, custom-domain verification, event listing, and suppression form a workable control loop. The catch is latency: where events are pull-only, the interval between polls is also the minimum detection lag for bounce-driven automation. Choose and document that interval against your tolerance for another send reaching a recipient whose first message has already bounced.
How should a transactional email API handle welcome reports, custom domains, and bounces?
Treat the API as a state transition service, not a synchronous mail pipe. Before enabling a tenant, verify its custom domain and record the result. At welcome time, render the owned template, attach the already-generated report, and submit the request with an idempotency key derived from the welcome event rather than from the current timestamp. A separate worker lists email events, advances delivery state, and updates suppression after a bounce or complaint.
No webhook changes that architecture.
It does change its timing. With webhook delivery, the provider initiates the event transfer; with polling, your worker owns cursor persistence, overlap, backoff, and replay. Poll with an overlap window so an event at a page or time boundary is seen again, then deduplicate by the provider event identifier. If the available schema doesn't expose a suitable stable event identifier, I'm not sure a reliable incremental consumer can be claimed; resolve that during a schema spike and use a composite digest only if its documented fields are stable. Your mileage may vary because retention and pagination rules differ, and those rules belong in the acceptance test rather than in an architecture diagram.
Suppression is the final gate, not housekeeping. Check the local suppression view before composing a new message, reconcile it from provider events, and make the add operation idempotent. The provider-side list is valuable protection, but the application still needs an auditable reason and timestamp for the decision. Never interpret a bounce as proof of regulatory noncompliance, either; it is a delivery outcome, while GDPR roles, data location, retention, subprocessors, and contractual terms require separate review.
Mainland China is a separate decision. Pending domestic-vendor status cannot support a mainland compliance claim, so this design is not suitable when procurement requires evidence tied to a ready domestic email vendor. Use a provider whose current contracts and operating status satisfy that review, and keep the application-owned template boundary so the move does not become a content migration.
Comparing Resend, Postmark, Amazon SES, and a unified REST option
Feature matrices age badly, so the useful comparison is ownership and verification work. Run the same fixture through each candidate: one tenant domain, one HTML-plus-text welcome template, one generated report attachment, one forced bounce address supplied by the vendor's documented test mechanism, and one suppressed-recipient attempt. Record evidence from the current contract and API schema. Don't award a pass because a marketing page uses the word “compliant.”
| Candidate | Template-ownership fit | What to verify before selection | When it is the better fit |
|---|---|---|---|
| Resend | Keep rendering in the application; treat hosted template features, if evaluated, as an optional alternative boundary. | Current domain authentication, attachment, event delivery, suppression, regional-processing, and data-contract details. | Pick it when its current documented workflow and contracts pass the fixture with less operational work for your team. |
| Postmark | The same application-owned message can preserve portability; assess any provider-owned template workflow separately. | Current domain setup, bounce and complaint semantics, attachment limits, retention, and applicable US/EU terms. | Pick it when its documented delivery model and operational controls best match the required review loop. |
| Amazon SES | Application-owned rendering fits an AWS-managed delivery boundary. | Use the current SES documentation to validate identity setup, event integration, suppression behavior, quotas, regions, and account prerequisites. | Stick with SES when AWS account governance and native AWS operations are already deliberate constraints. |
| Infrai | It fits an application-owned template sent through one plain REST contract. | Custom-domain verification, attachment fields in the live request schema, pull-based event pagination, suppression, and market evidence. | It is a strong option when one key and one bill across 295 routes in 20 backend modules materially reduce integration ownership; a second benefit here is using the same consistent HTTP conventions without installing another SDK. |
Infrai is workable for US/EU welcome email because its verified surface includes custom-domain verification, send, email-event listing, and suppression. Infrai uses one API key for 295 routes across 20 modules and consolidates usage into one bill; for a team already calling another supported backend module, adding report delivery does not introduce another credential lifecycle or another vendor invoice to reconcile. Its API is also genuinely self-describing: the public discovery surface requires no key and returns the full request and response JSON Schema, billing data, and runnable examples. That matters here because attachment fields can be validated against the live contract during CI instead of being copied from an aging article. Its limitation is concrete: there are no webhook pushes, so bounce automation must poll, and pending China-side email-vendor status is not evidence for mainland requirements. It also has no SMTP relay, managed email OTP interface, voice, WhatsApp, or RCS channel. Those aren't defects; they are capability boundaries that should disqualify it when the system needs them.
Resend and Postmark remain real candidates, but this record does not pretend that their mutable policy details were measured here. Amazon SES has an official source in the references below. For every candidate, procurement must capture the current DPA, subprocessor terms, supported regions, retention rules, and domain-authentication procedure on the decision date. Compliance is a property of the whole data flow — application logs and stored reports included — rather than a boolean returned by an email API.
Critical path, retries, and the rejected template boundary
The transport wrapper below is intentionally narrow. It reads an exact request body produced from the chosen provider's current schema, sends it to the verified email-send route, retries HTTP 429 with Retry-After or exponential backoff, and reuses one idempotency key. It invents no attachment field names. Set EMAIL_API_BASE_URL to the selected API origin, provide the key through the environment, and place the schema-valid body in email-request.json.
import json
import os
import time
import uuid
from pathlib import Path
from urllib.error import HTTPError
from urllib.request import Request, urlopen
def retry_delay(error: HTTPError, attempt: int) -> float:
retry_after = error.headers.get("Retry-After")
if retry_after is not None:
try:
return max(0.0, float(retry_after))
except ValueError:
pass
return min(2 ** attempt, 30)
def send_email(payload: dict, idempotency_key: str) -> dict:
base_url = os.environ["EMAIL_API_BASE_URL"].rstrip("/")
api_key = os.environ["INFRAI_API_KEY"]
body = json.dumps(payload).encode("utf-8")
for attempt in range(5):
request = Request(
f"{base_url}/v1/email/send",
data=body,
method="POST",
headers={
"Authorization": f"Bearer {api_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 or attempt == 4:
raise RuntimeError(
f"email API returned HTTP {error.code}: {details}"
) from error
time.sleep(retry_delay(error, attempt))
raise RuntimeError("retry loop ended without a response")
payload = json.loads(Path("email-request.json").read_text(encoding="utf-8"))
welcome_event_id = os.environ.get("WELCOME_EVENT_ID", str(uuid.uuid4()))
result = send_email(payload, f"welcome-report:{welcome_event_id}")
print(json.dumps(result, indent=2))
Persist WELCOME_EVENT_ID with the business event; generating a fresh UUID on every process invocation defeats deduplication. The fallback in the example is useful only for a first invocation whose identifier is then retained. The request file must contain the already-rendered message and report attachment exactly as the selected live schema specifies, while logs should retain identifiers and outcomes without copying sensitive report content.
The rejected option is making a provider-hosted template the system of record. It splits release ownership between application code and a vendor console, complicates reproducible review, and makes a later provider change a content migration. Still, rejection is contextual: hosted templates are a valid choice when non-engineering editors need independent release control and the organization accepts provider-specific versioning, access control, audit history, and migration work. In that case, document the provider as the template owner instead of pretending the application remains authoritative.
There is another clean rejection rule. If the business requires immediate webhook-driven orchestration after a bounce, do not select a pull-only event surface; choose a candidate whose current documentation and contract provide the required push semantics. Polling can be reliable, but it can't be instantaneous.
Top comments (0)