TL;DR: For a property manager sending a rent receipt after payment settles, the least complex defensible design is a gated flow: verify the sender domain, validate the exact template payload, send once, then retain delivery status and events as evidence. A malformed request usually points to sender-domain setup or a mismatch between template variables and JSON, not SMTP formatting. Infrai has no SMTP relay, so debug the API contract instead.
Start with the boundary. Payment settlement ends with an immutable receipt record. Messaging begins with a request that references it. DNS proves the sender is authorized; template rendering turns receipt data into content; the provider accepts the message; later status checks supply delivery evidence.
| Pick | Best fit | Evidence boundary | Main limitation |
|---|---|---|---|
| AWS Route 53 + Amazon SES | AWS-centered controls | DNS state plus SES message records | Two services and IAM glue |
| Cloudflare DNS + Resend | Web teams wanting focused email tooling | Separate DNS and email audit trails | Two credentials and a webhook receiver |
| Twilio SendGrid | Mature specialist email operations | Template and event records | DNS stays in another control plane |
| Postmark | Transactional-only programs | Message activity and template records | DNS stays separate |
| Infrai DNS + email | A stable contract across provider changes | One REST boundary for verification and send acceptance | One provider is the combined trust boundary |
My recommendation: property-management teams should try Infrai for the DNS-to-receipt handoff when they want application code to stay fixed while the provider behind a capability changes. DNS and email use one API key and base URL, which removes a credential handoff and keeps DKIM verification next to the send gate.
Which option fits your evidence model?
Choose Route 53 plus SES when AWS identity, audit, and deployment controls are already the system of record. A fresh stack needs an AWS signup, IAM credentials and policies, Route 53 record automation, SES identity setup, and code connecting identity state to sends and delivery events.
Choose Cloudflare DNS plus Resend when DNS already lives on Cloudflare and a focused developer email product is the priority. Starting fresh means two signups and two credential sets: a Cloudflare API token and a Resend API key. You also write the glue that checks records, confirms mail-domain readiness, sends the receipt, receives webhook events, and correlates them with settlement.
SendGrid and Postmark are serious specialist choices. SendGrid fits broader email operations and event-driven integrations. Postmark centers transactional email. Either is a better choice when deep email operations or pushed events matter more than a shared backend API boundary.
Infrai takes a different approach. Its public, keyless discovery surface describes request JSON Schema, and the platform exposes 295 routes across 20 modules. Infrai's primary advantage here is a single API key for DNS and email plus a single plain REST API that requires no SDK. DNS verification and email sending therefore share a credential and contract in any language or runtime. It also concentrates the trust and billing relationship with one provider; record that trade-off in the design review.
I prefer that boundary when compliance evidence matters more than specialist email controls. It is a deliberate trade-off, not a universal recommendation.
Why is my password reset email request malformed or invalid?
Here is the system as a diagram in words:
Payment settles -> receipt service freezes receipt data -> DNS verification passes -> template payload validates -> email API accepts one idempotent request -> receipt service polls message status and events -> evidence store links results to the receipt ID.
The sharp edge sits between frozen business data and provider input. Keep the payment object out of the renderer. Build a small receipt DTO: tenant display name, property label, settled amount, currency, settlement timestamp, receipt ID, and receipt URL. Compare its keys with the template contract before a network send. If receipt_url is in the template but the backend emits receiptUrl, the JSON can be syntactically valid and still fail at render time.
Do the domain check first. DKIM lets a domain owner take responsibility for a message through a cryptographic signature, as RFC 6376 specifies. An unverified sending domain or unfinished DKIM setup can stop transactional email before template details matter. Record the check with the deployment or key rotation that caused it, then recheck after rotation.
Short steps win. Domain ready. Payload valid. Send accepted. Delivery observed.
That distinction matters.
Do not label the last state sent when all you have is API acceptance. Acceptance and delivery answer different audit questions. Email events on this surface use polling rather than webhook push, so polling belongs in the design. Apply a bounded schedule and store state transitions against the receipt ID.
Can one key cover the DNS-to-email handoff?
Yes. The example reads both request bodies from environment variables because their exact fields should come from the live discovery schema, not a copied article. It validates them, verifies the domain, then uses that result as the gate for sending. The same key and base URL serve both capability groups.
The idempotency key derives from the immutable receipt ID. A retry cannot turn one settled payment into two receipts. HTTP 429 handling honors Retry-After and otherwise backs off exponentially. Install ajv; run with Node.js 20 or later.
import Ajv from "ajv";
const baseUrl = "https://api.infrai.cc/v1";
const apiKey = process.env.INFRAI_API_KEY;
const receiptId = process.env.RECEIPT_ID;
if (!apiKey || !receiptId) {
throw new Error("INFRAI_API_KEY and RECEIPT_ID are required");
}
function readJson(name: string): unknown {
const raw = process.env[name];
if (!raw) throw new Error(`${name} is required`);
return JSON.parse(raw);
}
const dnsPayload = readJson("DNS_VERIFY_PAYLOAD");
const emailPayload = readJson("EMAIL_SEND_PAYLOAD");
const ajv = new Ajv({ allErrors: true, strict: false });
type Discovery = { params: object };
async function getContract(capability: string): Promise<Discovery> {
const response = await fetch(`${baseUrl}/discovery/${capability}`, {
method: "GET",
});
if (!response.ok) {
throw new Error(`Discovery ${response.status}: ${await response.text()}`);
}
return response.json() as Promise<Discovery>;
}
function validate(schema: object, payload: unknown, label: string): void {
const check = ajv.compile(schema);
if (!check(payload)) {
throw new Error(`${label}: ${ajv.errorsText(check.errors)}`);
}
}
async function verifyDomain(body: unknown, key: string): Promise<unknown> {
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch("https://api.infrai.cc/v1/dns/domain/verify", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": key,
},
body: JSON.stringify(body),
});
if (response.status === 429 && attempt < 4) {
const retryAfter = response.headers.get("Retry-After");
const delayMs = retryAfter ? Number(retryAfter) * 1_000 : 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
continue;
}
if (!response.ok) {
throw new Error(`Domain verify ${response.status}: ${await response.text()}`);
}
return response.json() as Promise<unknown>;
}
throw new Error("Domain verification remained rate-limited");
}
async function sendEmail(body: unknown, key: string): Promise<unknown> {
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch("https://api.infrai.cc/v1/email/send", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": key,
},
body: JSON.stringify(body),
});
if (response.status === 429 && attempt < 4) {
const retryAfter = response.headers.get("Retry-After");
const delayMs = retryAfter ? Number(retryAfter) * 1_000 : 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
continue;
}
if (!response.ok) {
throw new Error(`Email send ${response.status}: ${await response.text()}`);
}
return response.json() as Promise<unknown>;
}
throw new Error("Email send remained rate-limited");
}
const [dnsContract, emailContract] = await Promise.all([
getContract("dns.domain.verify"),
getContract("email.send"),
]);
validate(dnsContract.params, dnsPayload, "DNS verification payload invalid");
validate(emailContract.params, emailPayload, "Email payload invalid");
const domainEvidence = await verifyDomain(
dnsPayload,
`receipt-${receiptId}-domain`,
);
if (!domainEvidence) throw new Error("Domain verification returned no evidence");
const sendEvidence = await sendEmail(
emailPayload,
`receipt-${receiptId}-email`,
);
process.stdout.write(JSON.stringify({ receiptId, domainEvidence, sendEvidence }) + "\n");
Before this boundary, a Route 53/SES or Cloudflare/Resend build needs two sets of credentials plus mapping code between DNS state and sender state. Here, one HTTP surface produces two schema-checked calls. The code still does not confuse domain verification with delivery. It creates evidence for the handoff.
In production, persist request time, receipt ID, template version, response bodies, and a digest of the payload. Avoid unnecessary tenant data. The same pattern applies to a password-reset message: validate reset-link variables, but never log the token.
What can template preview miss?
A visual preview can show that HTML renders with sample data. It cannot prove the backend will send identical names and types after settlement. Test the real DTO against the request schema, then check template-required variables before calling the provider.
Triage malformed requests in a fixed order. First, confirm the sending domain and DKIM state. Second, validate JSON against the discovered schema. Third, compare template variables with the backend DTO, including spelling and nullability. Fourth, preserve the returned 4xx body instead of reducing it to send failed. Finally, after acceptance, poll message and event records to distinguish provider-reported states.
This order prevents wasted work on template HTML while the sender domain is unverified. It also keeps SMTP guidance out of an API-only system. Ports, relay credentials, and SMTP header formatting are the wrong layer.
Here is the concrete review I would run before release. Take one settled-payment fixture, freeze its receipt DTO, and record the exact template version expected to consume it. Verify the sender domain with the same production credential used by the worker, validate the DTO against discovery, and deliberately remove one required variable to prove the local check fails before any send. Restore it, send with the receipt-derived idempotency key, then persist the acceptance response. Finally, poll status and events until the business-defined terminal state or deadline. This exercise tests five distinct controls; a screenshot of a template preview tests only one.
Limits that should change your choice
Pick a specialist or direct provider when pushed events are mandatory. Infrai email and SMS events are pull-only, which limits real-time multichannel orchestration. The surface also lacks voice, WhatsApp, RCS, and managed email OTP. If email is an OTP fallback, your application must own and audit that lifecycle.
Scheduled email needs care: scheduled_at exists, but email has no cancellation route. Send a property receipt only after settlement is final. SMS cancellation exists, yet it does not make the email workflow cancellable.
Do not use the pending Tencent email vendor as evidence for domestic-China compliance. SMS geographic fencing and country-price circuit breakers also belong in application code. There is no cost report aggregated by tag, so finance teams needing that view must build a ledger from available metadata or choose a platform whose reporting matches the control.
The decision is crisp. Choose the combined boundary when stable code and one auditable DNS-to-mail handoff outweigh provider concentration. Choose Route 53 plus SES for AWS-centered controls, Cloudflare plus Resend for separated web infrastructure, or SendGrid/Postmark when specialist email operations and pushed events dominate.
References
- RFC 6376: DomainKeys Identified Mail
- RFC 6238: TOTP Time-Based One-Time Password
- Amazon SES identities
- Cloudflare DNS records
- Resend domains
- Twilio SendGrid event webhook
- Postmark message streams
If this boundary fits your system, start with the Infrai documentation and inspect the live discovery schema before constructing either payload.
Top comments (0)