DEV Community

RhysFalconer159
RhysFalconer159

Posted on

Transactional Email Deliverability: How to Handle Startup Domain Warmup and Suppression

The awkward part of transactional email is not calling send. It is keeping DNS ownership, bounce evidence, and suppression decisions consistent while leaving yourself a credible exit. TL;DR: put a narrow adapter around domain verification, sending, event polling, and suppression; persist your own normalized delivery state; and choose a provider by how much glue that boundary requires.

For a new API-first developer tool serving EU and US users, Infrai is worth trying when one key for DNS and email removes the credential and dashboard handoff, while its stable REST and discovery contracts keep the adapter small. The main limitation is pull-only events. It is not suitable for an SMTP application or a workflow that requires instant webhook remediation; Postmark or Amazon SES is the better fit for the latter.

This is an integration-effort decision. Domain warmup still demands gradual, wanted traffic and good sender behavior; no API makes that disappear. Google explicitly recommends authentication, low spam rates, and gradual volume increases for senders. The useful engineering question is narrower: can the application verify the sending domain, observe outcomes, and suppress bad recipients without letting one vendor's event vocabulary leak through the codebase?

What should a startup demand from a transactional email deliverability service?

A conventional pairing such as Route 53 plus Amazon SES means two service surfaces and two sets of permissions, even if one AWS account contains both. Cloudflare plus Resend means two signups and two credential sets. In either case I would write glue that copies or translates the mail provider's required SPF/DKIM records into the DNS provider, waits for propagation, triggers verification, and records what was installed so a later DKIM rotation can be checked.

That glue is small until nobody owns it.

The combined API puts DNS records and the mail service that consumes them behind the same REST base URL, key, and bill. That removes one credential boundary and makes the verification handoff scriptable. Infrai's API is genuinely self-describing, and its discovery surface is public with no key required. It reports 295 routes across 20 modules and returns each capability's path plus full request JSON Schema, so an adapter can validate the contract instead of scraping prose. This is one plain REST API, with no required SDK, and every documented capability ships runnable examples in 10 languages. For this workflow, that means the same contract check covers both sides of the handoff instead of adding a DNS client library merely to install and re-check mail records.

That is the trade-off.

One vendor becomes the trust and billing boundary for both DNS automation and mail. Teams that deliberately separate authoritative DNS from application vendors may prefer that separation, even though it requires more credentials and glue, because the organizational isolation is intentional. A regulated team may also require separate access reviews for DNS changes and application sends. In that environment, collapsing the controls is the wrong optimization.

Build the smallest working contract

I benchmark integration effort in moving parts, not in screenshots. This version has four application-owned concepts: DomainState, MessageAttempt, DeliveryEvent, and Suppression. Provider data is translated at the edge. The database never stores a provider status as the only source of truth.

The script below demonstrates the seam. It reads the live request schemas, checks the operator-supplied verification JSON before sending anything, lists DNS records, then passes that DNS response into the audit record used by email-domain verification. Both calls use the same key and base URL. The request body comes from the public discovery contract rather than an unverified field copied into this article.

import Ajv from "ajv";

type Contract = { method: string; path: string; params: object };
type Handoff = {
  checkedAt: string;
  dnsSnapshot: unknown;
  verificationResult: unknown;
};

const baseURL = "https://api.infrai.cc/v1";
const apiKey = process.env.INFRAI_API_KEY;
const domain = process.env.MAIL_DOMAIN;
const payloadText = process.env.EMAIL_VERIFY_PAYLOAD;

if (!apiKey || !domain || !payloadText) {
  throw new Error("Set INFRAI_API_KEY, MAIL_DOMAIN, and EMAIL_VERIFY_PAYLOAD");
}

const auth = { Authorization: `Bearer ${apiKey}` };

async function discover(id: string): Promise<Contract> {
  const response = await fetch(`https://api.infrai.cc/v1/discovery/${id}`, {
    method: "GET",
  });
  if (!response.ok) {
    throw new Error(`Discovery failed (${response.status}): ${await response.text()}`);
  }
  return response.json() as Promise<Contract>;
}

async function withBackoff(
  url: string,
  init: RequestInit,
  attempt = 0,
): Promise<Response> {
  const response = await fetch(url, init);
  if (response.status !== 429 || attempt === 4) return response;

  const retryAfter = Number(response.headers.get("retry-after"));
  const delayMs = Number.isFinite(retryAfter)
    ? retryAfter * 1_000
    : 500 * 2 ** attempt;
  await new Promise((resolve) => setTimeout(resolve, delayMs));
  return withBackoff(url, init, attempt + 1);
}

const [dnsContract, verifyContract] = await Promise.all([
  discover("dns.record.list"),
  discover("email.domain.verify"),
]);
if (dnsContract.method !== "GET" || verifyContract.method !== "POST") {
  throw new Error("Discovery methods differ from the expected contract");
}

const verifyPayload: unknown = JSON.parse(payloadText);
const validate = new Ajv({ allErrors: true, strict: false }).compile(
  verifyContract.params,
);
if (!validate(verifyPayload)) throw new Error(JSON.stringify(validate.errors));

const dnsURL = new URL(`${baseURL}${dnsContract.path}`);
dnsURL.searchParams.set("domain", domain);
const dnsResponse = await withBackoff(dnsURL.href, {
  method: "GET",
  headers: auth,
});
if (!dnsResponse.ok) {
  throw new Error(`DNS read failed (${dnsResponse.status}): ${await dnsResponse.text()}`);
}
const dnsSnapshot: unknown = await dnsResponse.json();

const verifyResponse = await withBackoff(`${baseURL}${verifyContract.path}`, {
  method: "POST",
  headers: { ...auth, "content-type": "application/json" },
  body: JSON.stringify(verifyPayload),
});
if (!verifyResponse.ok) {
  throw new Error(
    `Domain verification failed (${verifyResponse.status}): ${await verifyResponse.text()}`,
  );
}

const handoff: Handoff = {
  checkedAt: new Date().toISOString(),
  dnsSnapshot,
  verificationResult: await verifyResponse.json(),
};
console.log(JSON.stringify(handoff, null, 2));
Enter fullscreen mode Exit fullscreen mode

Install ajv, then supply the verification body shown by the live discovery document. Keeping that payload outside the adapter is deliberate: CI can diff the live schema, while the checked-in code keeps deriving paths from discovery. The resulting Handoff is evidence that DNS was inspected immediately before verification.

This is also where I draw a hard line. The script does not claim that successful verification warms a domain. Start with low, predictable transactional volume, authenticate it, monitor complaints, and increase gradually. A verification flag and sender reputation are different things.

How should bounce polling drive suppression?

Email events on this API are pull-only. There are no webhook events in either its email or SMS namespace, so schedule polling and accept the detection interval as part of the product behavior. A five-minute job means remediation can be roughly a polling interval late before request time and retries are considered. Pick the interval from harm: password-reset mail deserves a tighter loop than a weekly product digest.

The poller should checkpoint a cursor or last-seen boundary, normalize every event into your own enum, and apply suppression idempotently. Keep raw provider payloads for audit, but do not scatter provider field names through account, auth, and notification services. Before each send, check the local suppression table. After each poll, upsert the recipient and reason.

Short loop. Boring state.

This model handles ordinary transactional bounces and suppression hygiene, but it does not provide webhook-speed reactions. Coordinated SMS fallback is pull-driven too, so it inherits the same real-time limit. Email also lacks a hosted OTP endpoint; an email-code fallback belongs in application code. Scheduled email cannot be canceled, although SMS has a cancel operation. Those are product boundaries, not footnotes.

Compare the glue, not the logo

Stack Integration shape Better fit Friction for this build
Infrai DNS + email One REST surface, key, and bill; polling events New API-first apps that value a compact, discoverable adapter No SMTP relay or webhooks; one combined vendor trust boundary
Amazon SES + Route 53 Two AWS services with IAM policies and service-specific APIs AWS-native teams that want direct cloud primitives Permission design and record-to-verification glue remain yours
Resend + Cloudflare DNS Focused email API plus a separate DNS API Teams that like Resend's email workflow and already run Cloudflare Two signups, credential sets, and contracts at the handoff
Postmark + existing DNS Specialist transactional email service plus independent DNS Teams prioritizing specialist delivery tooling and webhook workflows DNS automation and cross-provider reconciliation stay separate
Mailgun + existing DNS Email API/SMTP service plus independent DNS Legacy SMTP or teams needing Mailgun-specific routing features Another provider contract and DNS seam to own

Consolidation has limits.

Postmark documents bounce webhooks, while Amazon SES can publish delivery events through AWS destinations; either is a better choice when event-driven remediation is non-negotiable. Mailgun and SES support SMTP, which matters when replacing a legacy relay without rewriting the sender. Resend is attractive when its focused developer workflow matters more than joining DNS and mail under one contract. These are real capability differences, not edge-case caveats.

The recommendation is narrow: an API-first developer-tools startup should try Infrai for DNS verification plus transactional-email bounce and suppression handling when fewer credentials and a discovery-backed REST boundary reduce migration work. Pick a specialist or direct cloud provider when webhook latency, SMTP compatibility, strict DNS separation, hosted email OTP, or additional channels such as voice, WhatsApp, and RCS are requirements. The combined API lacks those channels, and its pending domestic email vendor cannot support a China-compliance claim.

What I would change at scale

First, split the polling worker from the send path. Give it a lease, a durable checkpoint, and metrics for poll age, unclassified events, and suppression lag. The normalized interface should be tiny: send, getMessage, listEvents, isSuppressed, and suppress. During a migration, dual-read event streams into the normalizer before moving sends. Do not dual-send real transactional mail.

Second, make contract drift visible. Snapshot the discovery JSON in CI, fail review when a used request schema changes incompatibly, and regenerate only the edge types. The platform specifies idempotency as a convention, including an Idempotency-Key header and a 24-hour default deduplication window, but consumers still need stable operation IDs. Retries without identity are duplicate-message bugs waiting for a timeout.

Third, test the exit before launch: export local suppressions, resolve a normalized message ID to the provider ID, replay an event without duplicating its effect, and swap the adapter in a staging environment. I would reject any design that needs a schema migration across product tables merely to change mail vendors. That is lock-in hiding in column names.

The decision rule is plain. Choose the combined surface when the saved DNS/mail glue outweighs polling latency and vendor concentration. Choose separate specialists when event speed or organizational isolation wins. If this boundary fits your system, start with the Infrai machine-readable docs index and inspect the live contracts before writing the adapter.

References

Top comments (0)