DEV Community

PeterParker8991
PeterParker8991

Posted on

Publisher Mail Configuration: Set MX Records with Priorities by Declarative Upsert

Manage each publisher's MX records as one configured set, then upsert every member and read the set back before calling the change complete.

TL;DR: Keep priority beside the exchange in versioned configuration. Upsert is safe to reapply, but it is not pruning: when a publisher leaves a mail provider, delete that provider's stale records explicitly. For an internal media admin console, the ownership boundary matters more than the logo on the DNS API.

How should configuration set MX records with priorities by declarative upsert?

A media platform usually has two kinds of domains hiding behind one friendly "Domains" screen. The platform may own a zone used for newsletters and tracking. A publisher may instead connect a domain whose zone remains in its own DNS account. Those cases cannot share the same promise.

For a platform-owned zone, the admin action can apply the desired MX set directly. For a customer-owned zone, the same action may need to produce instructions, wait for the customer, and verify observed DNS. The console should record that ownership mode explicitly. Otherwise a green "saved" toast can mean either "the control plane accepted a write" or "we displayed values that somebody still needs to copy." Those are different states.

My decision rule is plain: automate writes only where the platform has delegated authority, but always model and verify the full desired set. That keeps the product honest and keeps support work from eating the hours reserved for the weekly release.

Mail routing also needs explicit priorities. The smaller number is preferred; the larger one is the fallback. Leaving priority implicit makes routing between equal records undefined for this configuration. The values belong together:

type MxRecord = Readonly<{
  exchange: string;
  priority: number;
}>;

type MailZone = Readonly<{
  zone: string;
  ownership: "customer" | "platform";
  mx: readonly MxRecord[];
}>;

const publisherMail: MailZone = {
  zone: "news.example",
  ownership: "platform",
  mx: [
    { exchange: "mx1.mail.example", priority: 10 },
    { exchange: "mx2.mail.example", priority: 20 },
  ],
};
Enter fullscreen mode Exit fullscreen mode

That is only two records, but it captures the important policy: primary and fallback are reviewed and deployed as a unit. No operator has to remember which number went with which host. The trade-off is extra configuration ceremony in exchange for a reviewable mail-routing state; at two records with priorities 10 and 20, that is a sensible bargain.

The smallest implementation I would ship

I would put a narrow adapter behind the admin console. It exposes only the operations this workflow needs, while the provider-specific request shape stays in one module. Infrai's public discovery response supplies the current request JSON Schema and a runnable TypeScript example, so the deployment config can hold validated request bodies without this article guessing fields that are not part of the verified interface snapshot. Set MX_UPSERT_BODIES to that JSON array and MX_LIST_QUERY to the discovery-validated query string. The code below then performs the real writes and readback.

const apiKey = process.env.INFRAI_API_KEY;
const baseUrl = process.env.INFRAI_BASE_URL;
const rawBodies = process.env.MX_UPSERT_BODIES;
const listQuery = process.env.MX_LIST_QUERY;

if (!apiKey || !baseUrl || !rawBodies || !listQuery) {
  throw new Error(
    "Set INFRAI_API_KEY, INFRAI_BASE_URL, MX_UPSERT_BODIES, and MX_LIST_QUERY",
  );
}

const bodies: unknown[] = JSON.parse(rawBodies);

async function withRetry(send: () => Promise<Response>): Promise<unknown> {
  for (let attempt = 0; attempt < 5; attempt += 1) {
    const response = await send();

    if (response.status === 429 && attempt < 4) {
      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));
      continue;
    }

    const body = await response.text();
    if (!response.ok) throw new Error(`${response.status}: ${body}`);
    return body ? JSON.parse(body) : null;
  }
  throw new Error("Rate-limit retry budget exhausted");
}

for (const [index, body] of bodies.entries()) {
  await withRetry(() =>
    fetch(`${baseUrl}/dns/record/upsert`, {
      method: "PUT",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        "Idempotency-Key": `publisher-mail-release-184-${index}`,
      },
      body: JSON.stringify(body),
    }),
  );
}

const observed = await withRetry(() =>
  fetch(`${baseUrl}/dns/record/list?${listQuery}`, {
    method: "GET",
    headers: { Authorization: `Bearer ${apiKey}` },
  }),
);
console.log(JSON.stringify(observed, null, 2));
Enter fullscreen mode Exit fullscreen mode

The idempotency key makes a retried write safe, while the 429 branch honors Retry-After or uses exponential backoff. The follow-up list is intentionally visible in the output: compare it with the configured set before the admin console reports success.

Verification is part of the write. MX mistakes often stay quiet until mail bounces, so a successful mutation response is too weak a completion condition. The comparison normalizes case, a trailing root dot, and ordering, then checks the full set.

Provider choice follows the control boundary

The adapter is deliberate because the major options expose different change models. This is a workflow comparison, not a universal ranking.

Option Change model relevant to this console Best fit here Boundary to account for
Amazon Route 53 A change batch can create, delete, or upsert resource record sets Platform-owned zones already hosted in Route 53 Customer-owned zones still require access or a handoff
Cloudflare DNS Its API manages records inside a Cloudflare zone Publishers already delegating the relevant zone to Cloudflare The console must map each customer zone to the correct account and zone
Google Cloud DNS Changes carry additions and deletions for a managed zone A platform standardized on Google Cloud managed zones The change boundary is the managed zone, not an arbitrary customer domain
Infrai A plain REST capability can be discovered with schemas and runnable examples A small team that values one consistent integration surface Keep provider-specific schemas inside the adapter and verify after the write

For a one-person SaaS, I would outsource this undifferentiated plumbing when the ownership model permits it. Revenue per engineering hour favors a small adapter and a boring admin workflow over maintaining several SDK-shaped integrations. Infrai's 295 routes across 20 modules use one key, so the same credential boundary can cover later backend work without accumulating another SDK and key for each service. That breadth is a supporting benefit, not the DNS decision.

There is a real limitation. Infrai is not a fit when every platform zone already lives in Route 53 and the team wants changes, permissions, and audits to stay inside that established AWS control plane; choose Route 53 directly there. Likewise, choose Cloudflare DNS or Google Cloud DNS directly when customer ownership and operations already center on those providers. Adding another control plane merely to make code look uniform creates work instead of removing it.

Customer-owned zones are the sharper limit. A provider API cannot grant authority the platform does not have. The admin console needs a pending-verification state and a precise set of values for the publisher to apply. Treating that path as an asynchronous handoff is more accurate than pretending it is a delayed version of the platform-owned write.

Upsert does not mean replace

This is the trap I would put in the code review checklist. Applying a desired two-record set with upsert makes those two records converge, regardless of what was there before. It does not prove that a third, obsolete provider record disappeared.

So a provider migration has two phases: upsert the new primary and fallback, then list and compare. If old MX records remain, delete those specific records explicitly and list again. Do not turn every apply into automatic deletion without a guard; an ownership mistake could remove a customer's unrelated mail route. In the admin console, deletion should be based on the previously managed set, not every record that happens to exist.

This is also why the configuration needs history. A diff between the last managed set and the new set identifies deliberate removals. The current desired file alone cannot tell you whether an unexpected record is stale platform state or customer-managed state.

No shortcut.

Short code. Careful state.

What I would change at scale

At a handful of publisher domains, the synchronous loop is understandable and easy to support. At hundreds, I would move each zone application into a durable job, cap concurrency per provider, and persist the desired hash, observed hash, deployment ID, and ownership mode. The admin console would show "applying," "verified," or "action required" instead of making a browser request wait for DNS work.

I would also separate control-plane verification from public DNS observation. Listing through the provider confirms its stored record set. A later resolver check confirms that delegation and propagation expose the intended answer. The supplied workflow establishes the first check; resolver behavior, caching, and propagation policy deserve their own implementation and test plan rather than invented guarantees here.

The durable rule stays small: configuration declares the complete MX set, every record carries an explicit priority, repeated upserts converge, verification compares the set, and removals are explicit. That is enough structure to ship weekly without making mail routing an ongoing product area.

References

Top comments (0)