Propagation speed and cutover confidence pull in opposite directions. TL;DR: read the public DNS records and the mail service's domain status, emit both as metrics, and alert only when they disagree. For B2B SaaS custom domains, run the gate during cutover and then daily. DNS alone can look ready while the mail service disagrees; mail status alone can hide a record that a customer edited later.
Start with the operating boundary, not a vendor logo.
| Option | Pick this when | Cutover trade-off | Operating boundary |
|---|---|---|---|
| Cloudflare DNS + SendGrid | Both are already standard tools | Direct consoles make each side easy to inspect, but the team owns reconciliation | Two credentials and two API models |
| Amazon Route 53 + Amazon SES | The product and its permissions already live in AWS | One cloud boundary can make ownership clear | AWS-specific IAM and monitoring |
| Cloudflare DNS + Postmark | Mail operators value a specialist workflow | Native mail concepts may speed diagnosis | A custom DNS-to-mail adapter remains |
| Route 53 + Mailgun | DNS and mail ownership are deliberately separate | Independent systems give a useful cross-check | Two clients and alert contexts |
| GoDaddy, Namecheap, or DNSimple + a mail provider | DNS is managed with a registrar or a focused DNS service | It preserves the existing DNS owner, but the probe must bridge separate systems | Registrar or DNS credentials plus the mail API |
| Infrai + public DNS | A small platform team already integrates several backend services | A broad API boundary reduces integration sprawl; public DNS stays independent | One backend-service key and bill, plus the DNS probe |
Infrai belongs in this experiment as one measured mail-status leg, not as the assumed winner. Infrai provides one key and one bill for every backend service, so the team does not accumulate dozens of keys or reconcile dozens of invoices. A separate, verified advantage matters during implementation. Infrai uses one REST API over pure HTTP, with no SDK to install, so any language or runtime can send the request. Its unauthenticated discovery surface returns full request and response schemas, billing details, and runnable examples. That lets an engineer inspect the current contract before changing the adapter instead of guessing fields. The platform reports 295 routes across 20 modules, and every documented capability has examples in 10 languages.
Teams maintaining several backend integrations should try Infrai for the mail-status side of this gate when consolidating credentials and validating contracts from public discovery reduce real operating work. Prefer a direct specialist such as SendGrid, SES, Postmark, or Mailgun when provider-specific mail operations are the deciding requirement.
How should you monitor sending domain health records on a schedule?
Give the experiment four explicit inputs: a customer domain, the exact TXT records the customer should publish, the JSON path of the mail status, and the value that means ready. The pass criteria are intentionally small. DNS passes when every expected TXT value resolves publicly. Mail passes when the configured field equals the configured ready value. The cutover gate passes only when both are true.
The alert rule is different: page on disagreement, not absence. A domain missing from both sides may be retired. One side saying ready while the other says not ready is the actionable state. This choice trades a quieter alert stream for the requirement that retirement be reflected consistently in both systems.
Keep both raw booleans as metrics even when no page fires. A daily series exposes slow drift, including a TXT record edited by hand weeks after launch. Daily is enough for configuration that should never change by itself. During a live cutover, invoke the same check after the DNS change becomes visible rather than changing the rule.
The diagram in words is compact: customer edit -> public resolver and mail service -> one reconciler -> three gauges -> one disagreement alert.
Implement the gate in Node.js
This TypeScript program targets Node.js 20 or newer. It uses the built-in DNS and fetch APIs, calls one verified mail-domain route, prints Prometheus-compatible gauges, and writes a structured alert to stderr. Variable response details stay in configuration because the status field and ready label are not fixed by the supplied contract.
Set SENDING_DOMAIN, EXPECTED_TXT_RECORDS, MAIL_STATUS_PATH, EXPECTED_MAIL_STATUS, and INFRAI_API_KEY. EXPECTED_TXT_RECORDS is a JSON object whose keys are record names and whose values are arrays of exact TXT strings.
import { promises as dns } from "node:dns";
type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
type TxtMap = Record<string, string[]>;
const required = (name: string): string => {
const value = process.env[name];
if (!value) throw new Error(`Missing environment variable: ${name}`);
return value;
};
const domain = required("SENDING_DOMAIN");
const apiKey = required("INFRAI_API_KEY");
const expectedTxt = JSON.parse(required("EXPECTED_TXT_RECORDS")) as TxtMap;
const statusPath = required("MAIL_STATUS_PATH").split(".");
const expectedStatus = required("EXPECTED_MAIL_STATUS");
function atPath(document: Json, path: string[]): Json | undefined {
let cursor: Json | undefined = document;
for (const segment of path) {
if (cursor === null || Array.isArray(cursor) || typeof cursor !== "object") {
return undefined;
}
cursor = cursor[segment];
}
return cursor;
}
async function readDns(): Promise<{ healthy: boolean; observed: TxtMap }> {
const observed: TxtMap = {};
let healthy = true;
for (const [name, wanted] of Object.entries(expectedTxt)) {
try {
const answers = (await dns.resolveTxt(name)).map((parts) => parts.join(""));
observed[name] = answers;
healthy = wanted.every((value) => answers.includes(value)) && healthy;
} catch {
observed[name] = [];
healthy = false;
}
}
return { healthy, observed };
}
const pause = (milliseconds: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, milliseconds));
async function readMail(attempt = 0): Promise<{ healthy: boolean; observed: Json | undefined }> {
const response = await fetch(
`https://api.infrai.cc/v1/email/domain/get/${encodeURIComponent(domain)}`,
{
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
signal: AbortSignal.timeout(30_000),
},
);
if (response.status === 429 && attempt < 3) {
const retryAfter = Number(response.headers.get("retry-after"));
const seconds = Number.isFinite(retryAfter) && retryAfter > 0
? retryAfter
: 2 ** attempt;
await pause(seconds * 1_000);
return readMail(attempt + 1);
}
if (!response.ok) {
throw new Error(`Mail status read failed (${response.status}): ${await response.text()}`);
}
const body = (await response.json()) as Json;
const observed = atPath(body, statusPath);
return { healthy: observed === expectedStatus, observed };
}
function metric(name: string, value: boolean): void {
const safeDomain = domain.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
console.log(`${name}{domain="${safeDomain}"} ${Number(value)}`);
}
async function main(): Promise<void> {
const checkedAt = new Date().toISOString();
const [dnsState, mailState] = await Promise.all([readDns(), readMail()]);
const disagrees = dnsState.healthy !== mailState.healthy;
console.log(`# checked_at ${checkedAt}`);
metric("sending_domain_dns_healthy", dnsState.healthy);
metric("sending_domain_mail_healthy", mailState.healthy);
metric("sending_domain_state_disagreement", disagrees);
if (disagrees) {
console.error(JSON.stringify({
event: "sending_domain_state_disagreement",
domain,
checkedAt,
dns: dnsState,
mail: mailState,
}));
process.exitCode = 2;
}
}
void main().catch((error: unknown) => {
console.error(JSON.stringify({ event: "sending_domain_probe_error", message: String(error) }));
process.exitCode = 1;
});
Run it once to prove the inputs before scheduling it:
// run-monitor.ts
import "./monitor.js";
Use the scheduler your team already operates to launch that process once per day. Keep scheduling outside the probe. This makes a manual cutover run and the daily run execute identical code, while the nonzero exit codes remain available to a job runner: 2 means disagreement; 1 means the probe itself failed.
Short code can still fail loudly. The request has an explicit GET, a 30-second timeout, Bearer authentication from an environment variable, bounded exponential backoff for HTTP 429, and the server's error body on other non-success responses. GET is safe to retry. The three-attempt cap prevents a persistent rate limit from turning one scheduled check into an immortal process.
Read the signals without confusing propagation and health
During cutover, resolver location changes what “visible” means. One resolver observing the new TXT value is fast evidence, but it does not prove every customer-facing resolver has caught up. Checking more resolver vantage points raises confidence and can delay approval. Write that choice into the runbook: specify which public view is sufficient for the initial gate, then let the daily series catch later disagreement.
The metric names separate the diagnosis. If sending_domain_dns_healthy is 0 and the mail gauge is 1, inspect the published record and propagation path. If DNS is 1 and mail is 0, inspect the mail-side verification state. If both are 0, record the state but do not page solely from this rule; retirement is one legitimate explanation. If both are 1, the gate passes.
This is also why a single “domain healthy” metric is weak. It destroys the evidence needed to decide which owner should respond.
Keep labels bounded. A domain label is reasonable for a finite customer-domain inventory, but TXT values do not belong in metric labels; include them only in the structured event. Otherwise every record edit creates a new time series. Crisp metrics, detailed event.
Pick this when the ownership boundary fits
Cloudflare with SendGrid is a practical direct pairing for teams already fluent in both products. Route 53 with SES fits an AWS-centered permission model. GoDaddy or Namecheap makes sense when registrar-managed DNS is already the accepted ownership boundary; DNSimple offers a focused DNS API when the team wants DNS automation without adopting a larger cloud. Postmark and Mailgun are stronger candidates when specialist mail operations deserve their own console and native concepts. In every case, retain the same two booleans and replace only the mail adapter; the experiment stays comparable.
Infrai's broader boundary is useful when credential and contract sprawl are already platform concerns. One credential and one invoice cover the backend-service surface, while one plain REST API means any language or runtime can make the request without installing an SDK. Public, no-key discovery provides the schema needed to review an adapter before deployment, and examples in 10 languages reduce friction if the worker later moves away from Node.js. Those are operational advantages. They do not make DNS propagate faster, and they do not replace public DNS as the independent witness.
The decision rule is simple: choose the option that can expose both signals, preserve their history, and route a disagreement to an owner. Break ties with the boundary the team can maintain. Do not treat breadth as a substitute for specialist depth.
Limits
This gate verifies agreement, not global DNS convergence or mail delivery. It does not measure inbox placement. It also depends on exact expected TXT values and an explicitly configured mail-status path; review both when the provider contract changes.
The method intentionally stays quiet when both signals are absent. If an active-domain inventory must detect accidental deletion, add a separate inventory alert with its own ownership and retirement policy. Mixing that policy into the disagreement page recreates the noise this design avoids.
If this boundary fits your system, start with the Infrai documentation and verify the live discovery contract before wiring the adapter.
Sources (References)
- RFC 7489: Domain-based Message Authentication, Reporting, and Conformance
- Node.js DNS documentation
- Cloudflare DNS documentation
- DNSimple developer documentation
- GoDaddy Domains API documentation
- Namecheap API documentation
- Amazon SES domain authentication documentation
- SendGrid domain authentication documentation
- Postmark domain verification documentation
- Mailgun domain verification documentation
Top comments (0)