For a healthtech onboarding service, choose receiver evidence over a DNS dashboard when mail starts landing in spam. Read four signals together: the published SPF record count, published DMARC policy, a real message's SPF and DKIM alignment, and the sending-domain verification status. A common cause is simple: DMARC was published while neither SPF nor DKIM aligned with the visible From domain.
TL;DR: DNS presence is not authentication alignment. Forwarding routinely breaks SPF, so aligned DKIM is usually the path to preserve. Also count SPF records. Two SPF TXT records invalidate each other. Use the provider control plane to prove ownership, public DNS to observe publication, and the receiving mail system to judge the message.
A clinic can prove control of notify.clinic.example before onboarding completes and still send an appointment reminder authenticated under some other domain. Ownership passed. Alignment did not.
What should you check when mail goes to spam after SPF and DKIM setup?
DMARC compares the domain in the visible RFC5322 From header with authenticated identifiers. SPF aligns when the authenticated RFC5321 MailFrom domain aligns with that From domain. DKIM aligns when the d= domain in a passing signature aligns with it. At least one aligned path must pass. Publishing _dmarc creates policy and reporting; it does not create either path.
This distinction matters more than a green check beside three TXT records. A message can report spf=pass for an unaligned bounce domain and dkim=pass for an unaligned signing domain, yet fail DMARC. Publishing DMARC first improves nothing and can make delivery worse by reporting failures.
Verify from the mail side. The Authentication-Results header tells you what a receiver evaluated for a specific message. A DNS dashboard cannot show a message rewritten during forwarding, and your own zone read does not establish what a recipient's resolver observed.
The failure order I would test is deliberately dull: count SPF records, inspect a real received header, compare its identifiers with the visible From domain, then compare public records with the intended control-plane state. Start with a clinic reminder sent directly to a controlled mailbox. Record the visible From domain, the envelope domain reported for SPF, and every DKIM d= domain that passed. Repeat with a forwarded copy. If direct delivery aligns through SPF but forwarding does not, while the DKIM signature remains valid and aligned, the records are not giving contradictory answers; the two paths changed the evidence available to DMARC. That concrete split is why I choose DKIM as the alignment path to protect.
Dull is good.
It removes guesses.
The constraint that changes the choice
The onboarding flow must prove domain ownership before it completes. The deliverability check has a different job: detect drift between intended records, published records, and what a receiver authenticated. Treating those as one status creates a reassuring lie.
| Evidence surface | Question it answers | Blind spot | Use it for |
|---|---|---|---|
| DNS control plane | What did the operator intend to publish? | It does not represent every resolver's current answer | Ownership and record changes |
| Public recursive DNS | What is externally published now? | It cannot prove control of an account | Propagation and duplicate-SPF checks |
| Received message headers | What did this receiver authenticate? | It covers one message path | SPF, DKIM, and DMARC alignment |
| Sending-domain status | Did the onboarding verifier accept the domain? | Verification alone does not promise inbox placement | Gating onboarding completion |
Store these separately. intendedRecords, publishedRecords, messageAuthentication, and ownershipStatus are verbose names, but they expose the drift that matters. A single dnsStatus field saves config at the cost of diagnostic value.
There is another sharp edge. Do not guess a DKIM selector such as default. Read the selector from the real message's DKIM-Signature, then query that name. Guessing produces clean output for the wrong key.
The smallest useful Node.js probe
This TypeScript program checks both intent and publication without pretending to calculate a complete DMARC verdict. It calls the verified record-list route with Bearer authentication, queries the root TXT set and _dmarc, counts SPF records, and prints both evidence sets. It makes no assumption about the record-list response fields. Set INFRAI_BASE_URL to the service's versioned API base, set INFRAI_API_KEY, run it with Node.js through tsx, and pass the visible From domain.
import { resolveTxt } from "node:dns/promises";
async function listManagedRecords(baseUrl: string, apiKey: string): Promise<unknown> {
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(`${baseUrl}/dns/record/list`, {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status === 429 && attempt < 3) {
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;
}
if (!response.ok) {
throw new Error(`Record list failed (${response.status}): ${await response.text()}`);
}
return response.json() as Promise<unknown>;
}
throw new Error("Record list remained rate-limited after four attempts");
}
async function readTxt(name: string): Promise<string[]> {
try {
return (await resolveTxt(name)).map((chunks) => chunks.join(""));
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "ENODATA" || code === "ENOTFOUND") return [];
throw error;
}
}
async function main(): Promise<void> {
const domain = process.argv[2];
if (!domain) throw new Error("Usage: npx tsx check-mail-dns.ts <from-domain>");
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const baseUrl = process.env.INFRAI_BASE_URL;
if (!baseUrl) throw new Error("INFRAI_BASE_URL is required");
const [managedRecords, rootRecords, dmarcRecords] = await Promise.all([
listManagedRecords(baseUrl, apiKey),
readTxt(domain),
readTxt(`_dmarc.${domain}`),
]);
const spf = rootRecords.filter((value) => /^v=spf1(?:\s|$)/i.test(value));
const dmarc = dmarcRecords.filter((value) => /^v=dmarc1(?:;|$)/i.test(value));
console.log(JSON.stringify({
domain,
managedRecords,
spfRecordCount: spf.length,
multipleSpfRecords: spf.length > 1,
spf,
dmarc,
}, null, 2));
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});
If multipleSpfRecords is true, fix that before interpreting anything else. Multiple SPF TXT records invalidate each other. If the count is one, move to a received message and compare header.from with smtp.mailfrom and every passing header.d reported by the receiver. Forwarded traffic deserves its own sample because SPF routinely breaks there; DKIM alignment is usually the durable path.
This probe has a narrow contract. It does not infer organizational domains, relaxed versus strict alignment, or inbox placement. Use RFC 7489 and the receiver's own result for those semantics. Reputation, message content, sending behavior, and receiver policy sit outside this DNS diagnosis.
Native DNS APIs or one common REST surface?
Cloudflare DNS, Amazon Route 53, and Google Cloud DNS expose native control planes for zones hosted with those providers. Each is the direct choice when you already know where the zone lives or need provider-specific controls. They do not replace public resolution, and none can tell you how a receiver authenticated a particular message.
Infrai offers one key for everything and one plain REST API, with no SDK to install in any language or runtime. Its live discovery surface covers 295 routes across 20 modules. That breadth fits an onboarding backend that needs DNS plus other backend capabilities behind a consistent contract: adding the next capability is another endpoint rather than another credential model and configuration tree.
There is a second, separate advantage. The public discovery surface needs no key and returns full request and response schemas, billing information, and runnable examples; every documented capability has examples in 10 languages. For a CLI or generated client, that reduces the gap between finding a capability and making the first correct call. It also lets the implementation derive paths from discovery instead of copying prose into configuration.
The limitation is provider specificity. The common API is not a fit when DNS is the only external dependency, when the application relies on Cloudflare-, Route 53-, or Google Cloud DNS-specific controls, or when direct provider access is an operational requirement. Pick the native API in those cases. This is the trade-off: pick a common REST contract when several backend capabilities share the same service and eliminating SDK and credential sprawl outweighs provider-specific access.
The comparison is about integration shape, not mail truth. All four control-plane options still need public DNS observations and receiver-side authentication evidence. No API abstraction changes DMARC semantics.
What I would change at scale
Start with four timestamps: ownership verified, intended TXT updated, public TXT observed, and received message evaluated. Benchmark the workflow by time-to-first trustworthy state, not by SDK method count. DNS caches mean intended and published state can disagree temporarily, while receiver evidence cannot exist until a message arrives. Those clocks should not be compressed into one synchronous health flag just because the onboarding UI wants an immediate answer.
At low volume, run the probe after onboarding and inspect one real message. At higher volume, collect the evidence streams asynchronously and retain their timestamps. Test separate transactional paths too. Appointment reminders, staff notices, and forwarded mailbox traffic can use different envelope senders or DKIM signers.
One passing message proves one path. Nothing more.
The decision rule stays compact: complete ownership onboarding when its challenge is verified, but keep deliverability status separate until a receiver reports DMARC passing through aligned SPF or DKIM. Prefer aligned DKIM where forwarding is expected. Escalate record remediation when public state drifts from intent, and treat duplicate SPF records as invalid rather than merging them in application code.
That result is intentionally narrower than "will reach the inbox." It proves that ownership, publication, and message authentication agree. Inbox placement still depends on factors this check does not measure.
Further reading
- RFC 7489, Domain-based Message Authentication, Reporting, and Conformance: https://datatracker.ietf.org/doc/html/rfc7489
- Cloudflare DNS API: https://developers.cloudflare.com/api/resources/dns/
- Amazon Route 53 API Reference: https://docs.aws.amazon.com/Route53/latest/APIReference/Welcome.html
- Google Cloud DNS API: https://cloud.google.com/dns/docs/reference/v1
- Node.js DNS promises API: https://nodejs.org/api/dns.html#dnspromises-api
Top comments (0)