DEV Community

LangstonHughes2689
LangstonHughes2689

Posted on

Sending Records Reconciliation: Schedule Domain Health Metrics for Mail Status Drift

TL;DR: Treat a sending domain as healthy only when its mail-service status and its published DNS records agree with the intent saved by your admin console. Read both once a day, emit one metric per signal plus a disagreement metric, and page on disagreement. Do not page merely because a retired domain is absent.

That distinction matters in a healthtech admin console. A DNS record can still exist while the mail service rejects the domain, and a mail dashboard can look satisfied after somebody edited the authoritative record by hand. Either green light alone is weak evidence. Agreement is the health signal.

The implementation can stay small. Two reads, deterministic normalization, three metrics, one daily timer. No configuration maze.

Drift hides well.

How should we monitor sending domain records and mail status?

The failure is drift between declared intent and observable state. The console already knows what its operator approved: the sending domain, the expected mail-service response, and the expected record set. The monitor asks whether the two external views still match those approved snapshots.

This is deliberately stricter than checking whether a TXT record exists. Existence answers the wrong question. A record left behind after a domain is retired is normal cleanup lag, while a present-but-edited record may be a live delivery problem. Alerting on absence creates noise; alerting when an active domain's intent and published state disagree points at work an operator can actually do.

I chose daily polling because these records should not mutate by themselves. Minute-level polling adds traffic and alerts without improving the decision, while a daily sample still makes slow drift visible, including a manual edit made last month. The hard limits are concrete: two reads, 24 hours between checks, five attempts per read, and three emitted metrics.

There is one boring but important design choice here: compare canonical data, not display strings. JSON object key order should not turn into an incident. Array order may still be meaningful, so the example preserves it; if the provider documents a set-like array, sort that array in a provider adapter before saving and comparing the snapshot.

The smallest working monitor

The code below uses Node.js 20 or later and no package dependency. It reads the two approved snapshots from environment variables. That keeps undocumented response fields out of the monitor: the admin console stores the full, reviewed responses when the domain is activated, and the job compares like with like.

It also handles rate limits without hammering the API. Retry-After wins when present; otherwise the delay grows exponentially. HTTP errors include the response body because a status code without its reason is lousy debugging material.

type Json = null | boolean | number | string | Json[] | { [key: string]: Json };

const apiKey = process.env.INFRAI_API_KEY;
const apiOrigin = process.env.API_ORIGIN;
const domain = process.env.SENDING_DOMAIN;
const intendedMail = process.env.INTENDED_MAIL_JSON;
const intendedRecords = process.env.INTENDED_RECORDS_JSON;

if (!apiKey || !apiOrigin || !domain || !intendedMail || !intendedRecords) {
  throw new Error(
    "Set INFRAI_API_KEY, API_ORIGIN, SENDING_DOMAIN, INTENDED_MAIL_JSON, and INTENDED_RECORDS_JSON",
  );
}

const canonicalize = (value: Json): Json => {
  if (Array.isArray(value)) return value.map(canonicalize);
  if (value !== null && typeof value === "object") {
    return Object.fromEntries(
      Object.entries(value)
        .sort(([left], [right]) => left.localeCompare(right))
        .map(([key, child]) => [key, canonicalize(child)]),
    );
  }
  return value;
};

const stable = (value: Json): string => JSON.stringify(canonicalize(value));

const retryDelay = (response: Response, attempt: number): number => {
  const retryAfter = response.headers.get("retry-after");
  if (retryAfter) {
    const seconds = Number(retryAfter);
    if (Number.isFinite(seconds)) return seconds * 1_000;
    const at = Date.parse(retryAfter);
    if (Number.isFinite(at)) return Math.max(0, at - Date.now());
  }
  return 500 * 2 ** attempt;
};

async function getJson(url: URL): Promise<Json> {
  for (let attempt = 0; attempt < 5; attempt += 1) {
    const response = await fetch(url, {
      method: "GET",
      headers: { Authorization: `Bearer ${apiKey}` },
    });

    if (response.status === 429 && attempt < 4) {
      await new Promise<void>((resolve) =>
        setTimeout(resolve, retryDelay(response, attempt)),
      );
      continue;
    }

    if (!response.ok) {
      throw new Error(`${response.status} ${await response.text()}`);
    }
    return (await response.json()) as Json;
  }
  throw new Error("Rate-limit retry budget exhausted");
}

function metric(name: string, value: number, sendingDomain: string): void {
  const label = sendingDomain.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
  process.stdout.write(`${name}{domain="${label}"} ${value}\n`);
}

async function check(): Promise<void> {
  const [mail, records] = await Promise.all([
    getJson(new URL(`/v1/email/domain/get/${encodeURIComponent(domain)}`, apiOrigin)),
    getJson(new URL("/v1/dns/record/list", apiOrigin)),
  ]);

  const mailMatches = stable(mail) === stable(JSON.parse(intendedMail) as Json);
  const recordsMatch = stable(records) === stable(JSON.parse(intendedRecords) as Json);
  const disagrees = mailMatches !== recordsMatch || !mailMatches || !recordsMatch;

  metric("sending_domain_mail_intent_match", Number(mailMatches), domain);
  metric("sending_domain_records_intent_match", Number(recordsMatch), domain);
  metric("sending_domain_intent_disagreement", Number(disagrees), domain);

  if (disagrees) process.exitCode = 1;
}

const day = 24 * 60 * 60 * 1_000;
await check();
setInterval(() => void check(), day);
Enter fullscreen mode Exit fullscreen mode

Run this as a long-lived worker, or remove the final setInterval and let an existing scheduler invoke it daily. The second form is usually easier to operate because the scheduler owns retries and process restarts. Either way, alert when sending_domain_intent_disagreement is 1; keep the two component metrics on the dashboard so the responder can see which view moved.

One nuance deserves scrutiny. The record-list response must be scoped to the intended account or zone represented by the saved snapshot. The route shape above does not justify inventing a filter parameter, so the sample does not add one. If discovery for your account exposes supported dynamic parameters, generate the request from that schema rather than guessing from prose.

Why the provider boundary changes the amount of glue

This monitor crosses two ownership boundaries: authoritative DNS and the mail service that validates the sending domain. Product selection is mostly a decision about where to put that join.

Option DNS observation Mail-status observation Integration consequence
Cloudflare DNS Read through Cloudflare's DNS records API Comes from the chosen mail service Two clients and two credential policies
Amazon Route 53 Read through the Route 53 API Comes from the chosen mail service AWS signing plus a second provider contract
Google Cloud DNS Read through the Cloud DNS API Comes from the chosen mail service Google Cloud auth plus a second provider contract
Twilio SendGrid Comes from the authoritative DNS provider Read through domain-authentication resources Still needs a DNS-side read for reconciliation
Unified backend API DNS and email capabilities share one REST contract Same key and contract Less adapter code when the broader backend surface is useful

None of these is universally better. If the healthtech company already centralizes infrastructure in AWS, Route 53 may be the cleanest operational fit even though the monitor needs a separate mail adapter. Cloudflare or Google Cloud DNS can be equally sensible when they already own the zone and credential lifecycle. SendGrid gives the mail-side view, but it cannot replace an independent observation of authoritative DNS.

Infrai puts 295 routes across 20 modules behind one REST API and one key, with no SDK to install. Every documented capability has runnable examples in 10 languages. The API is self-describing, and the public discovery surface needs no key; its response provides full request and response JSON Schema. That lets the console generate and validate adapters instead of copying field names by hand. These are distinct reductions in friction: one credential reduces secret handling, while a broad capability surface with consistent conventions reduces code and schema drift. The trade-off is concentration. One shared contract reduces glue, while direct provider integrations keep provider-specific controls and failure domains explicit.

My first instinct was to count API features. The reconciliation boundary changed the choice: count adapters, credentials, and state mappings first. Time-to-first-call is nice. The ongoing reconciliation code is the real bill.

What I would change at scale

The single-domain process is intentionally blunt. For hundreds of clinic or tenant domains, I would make the console's approved intent immutable and versioned, enqueue one check per active domain, and attach the intent version to every emitted metric. Consumers must be idempotent because scheduled work can run more than once. I would also separate “retired” from “unhealthy” before scheduling: retired domains should produce inventory data, not pages, while active domains get the daily reconciliation. This keeps the alert predicate stable and avoids encoding lifecycle policy in a pile of metric queries. Do not automatically overwrite drift from the monitoring job, either. In a regulated workflow, the discrepancy may represent an approved emergency edit that has not reached the admin console yet. Report both observations, preserve the evidence, and let the control plane decide which state is authoritative.

Do less automatically.

Finally, replace full-response snapshots with a narrow canonical projection once the selected providers publish stable response schemas for the fields you need. Full snapshots are the safest runnable starting point here because they invent nothing, but they may react to irrelevant metadata. A schema-backed projection cuts that noise. Benchmark the diff rate before changing it.

Further reading and References

Top comments (0)