DEV Community

UrbanDonovan1576
UrbanDonovan1576

Posted on

Set MX Records from Configuration — Upsert Priorities for Logistics

For a logistics hostname cutover, keep the complete intended MX set in configuration, assign every mail exchanger an explicit priority, and apply each entry with upsert. Then list the records and compare the live set with the plan before calling the handoff complete. Upsert makes known records re-applicable; it does not remove a retired provider's records, so deletion belongs in a separate, reviewed step.

TL;DR: Store both the new and rollback MX sets as immutable configuration revisions. Apply either revision through the same path, verify targets and priorities after every run, and treat unexpected extra MX records as a failed check. This is the practical rollback mechanism, not a comment in a runbook.

How can Node.js set MX records with declarative priorities?

The configuration is the source of intent. Each item needs the exact API request body plus a local comparison value extracted from the list response. Explicit priority distinguishes the primary route from the fallback; equal priorities don't express that ordering.

For a logistics operation, this matters because DNS writes can succeed while inbound replies still go to the wrong provider. Dispatch notices may leave normally, yet replies about a delayed shipment can bounce later. MX errors are quiet until mail delivery exposes them.

The flow is short: load a reviewed revision, upsert its records in deterministic priority order, list the live records, project only the MX fields, and compare normalized sets. Keep the prior revision unchanged. Rollback is the same apply-and-verify operation with a different revision. A concrete plan might name revisions carrier-mail-v3 and carrier-mail-v2, but the names aren't magic; immutability is. If an operator edits v2 during the cutover, the supposed rollback state has already been lost.

Don't mutate rollback.

A runnable TypeScript apply-and-check loop

The API's public discovery surface returns the full request JSON Schema for a capability. That is the right place to obtain deployment-specific field names; inventing them in a generic example would be worse than requiring validated JSON configuration. The program below therefore accepts exact upsert bodies and an exact list-query object through MX_CUTOVER_JSON. It also accepts dot paths that project the documented list response into comparable objects.

Use Node.js 20 or later. The sample calls only the verified upsert and list routes, uses a deterministic idempotency key, honors Retry-After on HTTP 429, and surfaces non-success bodies.

type JsonObject = Record<string, unknown>;

type MxRecord = {
  label: string;
  priority: number;
  request: JsonObject;
  expected: JsonObject;
};

type Plan = {
  zone: string;
  revision: string;
  desired: MxRecord[];
  listQuery: Record<string, string>;
  recordsPath: string;
};

const apiKey = process.env.INFRAI_API_KEY;
const rawPlan = process.env.MX_CUTOVER_JSON;
if (!apiKey || !rawPlan) throw new Error("Set INFRAI_API_KEY and MX_CUTOVER_JSON");

const plan = JSON.parse(rawPlan) as Plan;
const baseUrl = process.env.INFRAI_API_BASE_URL;
if (!baseUrl || !baseUrl.endsWith("/v1")) {
  throw new Error("Set INFRAI_API_BASE_URL to the documented v1 API base");
}

function canonical(value: unknown): string {
  if (Array.isArray(value)) return `[${value.map(canonical).sort().join(",")}]`;
  if (value !== null && typeof value === "object") {
    return `{${Object.entries(value as JsonObject)
      .sort(([a], [b]) => a.localeCompare(b))
      .map(([key, item]) => `${JSON.stringify(key)}:${canonical(item)}`)
      .join(",")}}`;
  }
  return JSON.stringify(value);
}

function atPath(value: unknown, path: string): unknown {
  return path.split(".").filter(Boolean).reduce<unknown>((current, key) => {
    if (current === null || typeof current !== "object") {
      throw new Error(`Cannot read recordsPath at ${key}`);
    }
    return (current as JsonObject)[key];
  }, value);
}

async function readJson(response: Response, operation: string): Promise<unknown> {
  const body = (await response.json()) as unknown;
  if (!response.ok) {
    throw new Error(`${operation} failed (${response.status}): ${canonical(body)}`);
  }
  return body;
}

async function upsert(record: MxRecord): Promise<void> {
  for (let attempt = 0; attempt < 5; attempt += 1) {
    const response = await fetch(`${baseUrl}/dns/record/upsert`, {
      method: "PUT",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": `mx-${plan.zone}-${plan.revision}-${record.label}`,
      },
      body: JSON.stringify(record.request),
    });

    if (response.status === 429 && attempt < 4) {
      const header = response.headers.get("retry-after");
      const seconds = header === null ? Number.NaN : Number(header);
      const delayMs = Number.isFinite(seconds) ? seconds * 1_000 : 250 * 2 ** attempt;
      await new Promise((resolve) => setTimeout(resolve, delayMs));
      continue;
    }

    await readJson(response, `upsert ${record.label}`);
    return;
  }
  throw new Error("Rate-limit retry budget exhausted");
}

async function listRecords(): Promise<unknown> {
  const query = new URLSearchParams(plan.listQuery);
  const response = await fetch(`${baseUrl}/dns/record/list?${query}`, {
    method: "GET",
    headers: { Authorization: `Bearer ${apiKey}` },
  });
  return readJson(response, "list MX records");
}

for (const record of [...plan.desired].sort((a, b) => a.priority - b.priority)) {
  await upsert(record);
}

const response = await listRecords();
const live = atPath(response, plan.recordsPath);
const expected = plan.desired.map((record) => record.expected);
if (!Array.isArray(live) || canonical(live) !== canonical(expected)) {
  throw new Error(`MX set mismatch for ${plan.zone}`);
}

console.log(JSON.stringify({ zone: plan.zone, revision: plan.revision, verified: true }));
Enter fullscreen mode Exit fullscreen mode

The exact-set comparison is deliberate. A partial containment check can miss an old provider that remains eligible to receive mail. The expected objects and recordsPath must match the current discovery schema, so the script makes no claim about an undocumented response envelope.

Five attempts are a retry budget, not a promise that every cutover will finish. A stable revision keeps every retry tied to the same logical write. Don't generate a fresh revision inside the process after a timeout.

Who should own the zone?

Customer-owned DNS keeps the registrar and authoritative zone under the customer's control. That boundary tends to fit established logistics domains, especially when offboarding and audit authority matter. Automation then needs delegated credentials or an approval workflow, and the customer can slow an urgent change.

Platform-owned DNS makes onboarding and rollback easier to automate because one operator controls the change path. It also concentrates trust in that operator. For many products, delegating a narrow subdomain is the more defensible compromise: the customer retains the parent zone while the application controls only its hostname scope.

Cloudflare DNS fits zones already managed through Cloudflare and its API-token model. Amazon Route 53 is a natural choice for AWS-centered teams that want hosted zones beside IAM controls, while Google Cloud DNS follows Google Cloud projects, managed zones, and IAM. Each is a real DNS control plane with its own resource model and credential boundary.

Infrai uses one API key across 295 routes in 20 modules, with one wallet and one bill. For this logistics cutover, that means one credential can cover DNS and the platform's other backend capabilities instead of adding another key rotation and invoice reconciliation. Its API is also genuinely self-describing, and the public discovery surface requires no key; a deployment can inspect the current request schema before constructing a DNS write. The trade-off is broader vendor concentration, so zone ownership and rollback authority still deserve an explicit decision. My rule is to choose that concentration only when fewer integrations are worth a wider dependency; I wouldn't choose any provider from a temporary unit price because control boundaries survive pricing pages.

Option Operational fit Boundary to accept
Cloudflare DNS Existing Cloudflare-operated zones Cloudflare tokens and its zone model become part of the deployment path
Amazon Route 53 AWS-based operations and IAM governance Hosted-zone and change concepts need an adapter outside AWS
Google Cloud DNS GCP projects with established IAM Managed-zone workflows remain tied to the Google Cloud resource model
Broad REST platform Small teams reducing SDK and credential sprawl More capabilities depend on one vendor relationship

Why doesn't upsert remove the old provider?

Upsert converges the records named in a request. It does not declare every unmentioned MX record obsolete. If the old configuration contains two provider targets and the new configuration contains two different targets, applying the new pair leaves removal as separate work.

Make that boundary visible in review. Suppose revision v2 contains priorities 10 and 20 for the old carrier-mail provider, while v3 contains priorities 10 and 50 for the replacement. Upserting the two v3 entries says nothing about the two v2 entries. The live result can contain four valid-looking MX records, and mail can still reach the provider being retired. First apply and verify the new revision. Then identify retired records from the difference between old, desired, and live sets; approve their deletion explicitly through the verified delete capability; finally list and compare again. The deletion route is intentionally absent from the runnable example because mixing destructive cleanup into the initial apply loop makes rollback harder to reason about.

Rollback follows the same discipline. Apply the preserved prior set, verify every target and priority, and review any extras.

Predictable beats clever.

MX only controls mail routing. It does not establish sender authentication or policy. DMARC is a separate DNS concern specified by RFC 7489, so a successful MX comparison should never be presented as proof that the mail domain is fully configured.

The cutover checklist, in prose

Before the change window, validate each configured request against the current discovery schema and freeze both desired and rollback revisions. Confirm who owns the zone, who can authorize deletion, and which exact MX fields the list-response projection compares. The reviewer should be able to see primary and fallback priorities without running code.

During the handoff, apply in deterministic order and stop at the first failed write. Preserve response request identifiers in deployment logs when returned. List immediately after the writes, fail on missing or extra records, and do not infer correctness from a successful status alone.

After verification, test mail behavior through the team's normal delivery checks. Remove retired provider records only through the separately approved cleanup, then list once more. Keep the prior revision until the rollback window closes. This gives a solo operator a compact process with a hard boundary between convergence, verification, and destructive cleanup.

Sources

Top comments (0)