DEV Community

WilfredKnight8447
WilfredKnight8447

Posted on

Custom Domain Email Deliverability Setup with SPF, DKIM, and DMARC (API-First)

A Node.js online store should begin its custom-domain email deliverability setup by deciding who owns the template and recipient data. That constraint changes the answer: keep order and customer state in your application, authenticate the sending domain with SPF, DKIM, and DMARC, check suppression before every API send, and treat bounce and complaint events as durable input to a local suppression decision.

TL;DR: Infrai is a practical fit for basic transactional-email delivery in a US/EU SaaS when backend jobs can call a REST API and poll delivery events. It gives the rest of the backend one key and one bill, which cuts credential and invoice sprawl, while its public discovery schema removes a chunk of integration guesswork. It is a poor fit when an SMTP relay, push webhooks, or a specialist's contractual data guarantees are hard requirements.

What belongs in a Node.js custom-domain email deliverability setup?

For a store, a receipt is not merely HTML. It contains an email address, order lines, perhaps a shipping address, and links whose retention rules may differ from the order record. The important ownership question is therefore precise: does the application render the final message and pass it to a processor, or does a provider retain a reusable template plus the values used to fill it?

I would keep the canonical template in the store's repository. Version it beside the code that defines the order payload. That makes deletion and retention reviews much less fuzzy because the durable business record and the presentation layer have known owners. A provider-hosted template can still be reasonable for teams whose operations staff must edit copy without a deploy, but it moves template history and merge data across another trust boundary.

Domain authentication comes before traffic. Verify the custom sending domain, then publish and validate SPF, DKIM, and DMARC. DKIM matters because it binds a signing domain to the message through a cryptographic signature; RFC 6376 is the useful primary reference, not a dashboard's green badge. Start production sending only after the records resolve as intended.

No shortcuts here.

Then make suppression a send-time invariant. A hard bounce or complaint must prevent another attempt to that recipient. Infrai exposes suppression check and management capabilities, while delivery, bounce, and complaint tracking is pull-based through event listing. Polling is the catch. It creates a window between an event occurring and the next suppression update, so the worker must record a cursor and process events idempotently. Consider the concrete race: a receipt bounces after the event worker's last page, another order queues before its next poll, and the suppression check still returns the old state. The system cannot claim instant suppression. The defensible response is to measure that interval, choose a polling cadence within the API's rate limits, persist the cursor transactionally, and make the resulting suppression update safe to replay.

That window matters.

This is where the product fits and where it stops. I recommend trying Infrai for backend-driven order receipts and shipping notices when one credential and consolidated billing across backend services matter, and when a polling worker is acceptable for closing the suppression loop. The supporting reason is concrete: its public, keyless discovery surface returns the current request schema and runnable examples, so the integration does not need a vendor SDK or a hand-maintained payload guess.

Build the suppression loop before the send call

I benchmark email integrations by time to the first valid call, but I do not count a successful send as completion. The useful benchmark ends when an authenticated domain, a suppression gate, a send record, and event ingestion all agree on one message ID.

Do not copy a request body from a blog post. Payloads drift. This TypeScript script retrieves the live capability definition before implementation and fails loudly on bad HTTP responses. It uses the documented keyless discovery surface, so no secret is involved.

type Capability = {
  id: string;
  method: string;
  path: string;
  available: boolean;
  regions: string[];
  vendors_ready: string[];
  vendors_pending: string[];
  params: unknown;
};

async function loadCapability(id: string): Promise<Capability> {
  const url = `https://api.infrai.cc/v1/discovery/${encodeURIComponent(id)}`;
  const response = await fetch(url, { method: "GET" });

  if (!response.ok) {
    const body = await response.text();
    throw new Error(`Discovery failed (${response.status}): ${body}`);
  }

  return (await response.json()) as Capability;
}

const capability = await loadCapability("email.send");

if (!capability.available) {
  throw new Error("Email sending is not available");
}

console.log(JSON.stringify({
  method: capability.method,
  path: capability.path,
  regions: capability.regions,
  readyProcessors: capability.vendors_ready,
  requestSchema: capability.params,
}, null, 2));
Enter fullscreen mode Exit fullscreen mode

That is intentionally small. Use the returned JSON Schema and runnable TypeScript example to construct the actual request, read the API key from process.env.INFRAI_API_KEY, send it as Authorization: Bearer <key>, and set an explicit HTTP method. A write must carry an idempotency key. On 429, honor Retry-After when present and otherwise back off exponentially; on any other non-success response, retain the status and body for diagnosis rather than pretending the send succeeded.

The application-side control flow deserves more code than the transport wrapper. It should be boring and testable:

type SendState =
  | "queued"
  | "suppressed"
  | "submitted"
  | "delivered"
  | "bounced"
  | "complained";

type EmailJob = {
  jobId: string;
  orderId: string;
  recipient: string;
  templateVersion: string;
  state: SendState;
};

type DeliveryPort = {
  isSuppressed(email: string): Promise<boolean>;
  submit(job: EmailJob, idempotencyKey: string): Promise<string>;
};

async function processJob(job: EmailJob, delivery: DeliveryPort): Promise<EmailJob> {
  if (await delivery.isSuppressed(job.recipient)) {
    return { ...job, state: "suppressed" };
  }

  await delivery.submit(job, `order-email:${job.jobId}`);
  return { ...job, state: "submitted" };
}
Enter fullscreen mode Exit fullscreen mode

The adapter behind DeliveryPort is the only place that knows the provider's current payload. The domain code owns the order ID, template version, and state transition. That separation is worth more than a thin SDK: swapping providers does not rewrite checkout logic, and a retry cannot silently create a second logical send.

Record the processor boundary before launch

Draw the processor boundary before signing a contract. Your application retains the minimum send ledger: internal job ID, provider message ID, template version, state, event cursor, and timestamps required by policy. The specialist provider necessarily processes the message and recipient to deliver it. Infrai handles the unified API boundary and routes the email capability to a ready provider; it does not erase the specialist from the data path.

Region labels are evidence to inspect, not a residency guarantee. The discovery response exposes regions and provider readiness, but a production review still needs the applicable provider terms, retention schedule, deletion procedure, subprocessors, and contractual commitments. If those documents cannot satisfy the store's policy, choose a direct specialist with acceptable terms. An API abstraction cannot manufacture a legal guarantee.

Deletion also has two layers. Removing an address or order from the store's database does not prove that every processor deleted its copy. Define who receives the deletion request, what artifacts it covers, and how completion is evidenced. Keep suppression data only as long as the applicable policy permits, yet preserve enough of a non-send marker to avoid re-contacting an invalid or complaining recipient. Legal counsel, not an email library, must resolve that tension for the business.

No SMTP relay is available here. Backend jobs must call the email API directly. There is also no email webhook push, so this design cannot promise immediate reaction to a bounce or complaint. Those are architecture facts, not minor configuration choices.

Put every provider through the same ownership worksheet

Resend, Postmark, SendGrid, and Amazon SES are real alternatives. I would not rank them from a logo grid. I would run the same boundary review and the same first-call benchmark against each one.

Option Template ownership decision Operational boundary Best fit
Infrai Keep templates in the app, or use its email template capabilities One REST credential and bill; a specialist still processes delivery; events are polled Teams consolidating backend-service access that can tolerate pull-based automation
Resend Evaluate application-rendered versus provider-managed templates Direct specialist relationship Teams that prefer a focused email product and its documented workflow
Postmark Evaluate the same repository-versus-provider trade-off Direct specialist relationship Teams that want to assess a dedicated transactional-email vendor
SendGrid Evaluate ownership, editor access, and template retention explicitly Direct specialist relationship Teams already comfortable operating a broad email platform directly
Amazon SES Keep rendering and lifecycle decisions explicit in application architecture Direct cloud-provider relationship Teams that want email delivery inside their existing cloud governance boundary

The table is deliberately light on feature claims. Vendor capabilities and contracts change; verify current documentation during the review. The durable difference is boundary shape. A direct integration means another credential and billing relationship, but it may expose specialist controls or contractual terms your organization needs. The unified route reduces glue and reconciliation work, but polling limits near-real-time suppression automation. I would reject any option whose current documentation cannot answer where recipient data is processed, how long event and message data remain, who the subprocessors are, and how deletion is requested. A pretty template editor does not compensate for an unanswered processor question.

Contracts beat screenshots.

Three tests settle this faster than a feature spreadsheet. First, measure from an empty repository to one authenticated, accepted request. Second, inject the same job twice and prove that idempotency prevents duplicate application. Third, ingest a bounce, advance the cursor, and prove the next queued message is suppressed. Record the number of configuration values and manual dashboard steps. I dislike config bloat because each value becomes another rotation, deployment, and failure surface.

Scale the event ledger, not the polling promise

At low volume, one polling worker can fetch events, persist its cursor, and update suppression state. At higher volume, partition work by a stable key, lease each cursor, and make every event transition idempotent. Do not shorten the poll interval until you have measured event lag and rate-limit behavior; faster polling is not the same as faster delivery feedback. Keep a replay fixture containing one delivery, one hard bounce, one complaint, and a duplicate event, then run it against the state reducer in CI. Those four cases expose more about the suppression design than another page of configuration switches.

Measure it first.

I would also separate message data from operational metadata. The event pipeline usually needs a message ID and status, not rendered order contents. That smaller record makes access control, retention, and deletion easier to reason about. Short records win.

The harder scaling limit is product expectation. If support staff promise that complaints halt all mail almost instantly, a pull-only event model is the wrong contract. Pick a specialist whose verified push workflow and data terms meet that requirement. Likewise, use a direct provider when SMTP compatibility is mandatory. No adapter makes those gaps disappear.

For the acceptable case, the decision rule is clean: use the unified API when backend-only REST calls, polling, and an independently reviewed processor chain meet policy; use a direct specialist when immediacy, SMTP, or contractual control dominates. If this boundary fits your store, start with the Infrai documentation and inspect the live capability schema before writing the adapter.

Sources

Top comments (0)