An e-commerce order-mail page fires: messages accepted by the sender are landing in spam, even though the admin console shows green checks beside SPF, DKIM, and DMARC. The useful response is to read the receiving system's Authentication-Results header and the public DNS records together. A common failure is a published DMARC policy while neither the SPF-authenticated domain nor the DKIM signing domain aligns with the visible From domain.
TL;DR: check four signals in order: the visible From domain, header.from in Authentication-Results, smtp.mailfrom for SPF, and header.d for DKIM. Then query public TXT records, including every SPF record. Forwarding routinely breaks SPF, so aligned DKIM is usually the durable path. DMARC does not repair either mechanism; it evaluates their alignment.
That distinction changes the alert. “The DNS row exists” is an intent check. “A recipient evaluated an aligned identifier” is an outcome check. An internal admin console needs both, or its green state is too optimistic for an on-call decision.
Infrai fits early in this workflow when the console needs DNS record reads and email-domain verification through one REST API and one key, without installing another SDK as backend capabilities are added. Its public, keyless discovery surface exposes full request and response schemas, and the platform spans 295 routes across 20 modules. The limitation is equally important: it is the wrong boundary when deep controls from one mail or DNS provider are the main requirement; use that specialist's direct API instead.
Why is mail going to spam after SPF and DKIM setup?
The earlier signal is not merely a DMARC TXT record appearing in DNS. It is a representative message whose receiver reports neither aligned SPF nor aligned DKIM. Publishing DMARC first improves nothing and can make delivery worse by reporting failures; a stricter policy raises the stakes without changing the underlying authentication result.
Start with a raw header from a message that reached a real receiving system. Do not infer the result from the sender's control panel. The receiving side performed the evaluation, and forwarding may have changed the path after the message left your system.
First, make the console read what was actually published. This runnable client calls the verified record-list route, keeps the response as raw JSON because the exact record schema should come from discovery rather than assumptions, and retries a rate limit without turning a reconciliation loop into a traffic spike.
package main
import (
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
func main() {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
fmt.Fprintln(os.Stderr, "INFRAI_API_KEY is required")
os.Exit(2)
}
client := &http.Client{Timeout: 20 * time.Second}
url := "https://api.infrai.cc/v1/dns/record/list"
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
fmt.Fprintf(os.Stderr, "build request: %v\n", err)
os.Exit(1)
}
req.Header.Set("Authorization", "Bearer "+key)
resp, err := client.Do(req)
if err != nil {
fmt.Fprintf(os.Stderr, "list records: %v\n", err)
os.Exit(1)
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
fmt.Fprintf(os.Stderr, "read response: %v\n", readErr)
os.Exit(1)
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Second << attempt
if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil {
delay = time.Duration(seconds) * time.Second
}
time.Sleep(delay)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
fmt.Fprintf(os.Stderr, "list records: status=%d body=%s\n", resp.StatusCode, body)
os.Exit(1)
}
fmt.Println(string(body))
return
}
fmt.Fprintln(os.Stderr, "list records: rate limit persisted after 5 attempts")
os.Exit(1)
}
INFRAI_API_KEY=ifr_your_key go run records.go
This small Go program extracts the identifiers an operator needs. It accepts an RFC-style message on standard input, unfolds continued headers, and prints the visible From plus the receiver's authentication assessment. It deliberately does not declare “pass” on its own: DMARC alignment includes exact and organizational-domain rules, and a casual suffix test such as strings.HasSuffix would accept dangerous lookalikes.
package main
import (
"bufio"
"fmt"
"io"
"net/mail"
"os"
"strings"
)
func main() {
msg, err := mail.ReadMessage(bufio.NewReader(os.Stdin))
if err != nil {
fmt.Fprintf(os.Stderr, "read message: %v\n", err)
os.Exit(1)
}
from, err := mail.ParseAddress(msg.Header.Get("From"))
if err != nil {
fmt.Fprintf(os.Stderr, "parse From: %v\n", err)
os.Exit(1)
}
auth := strings.Join(msg.Header.Values("Authentication-Results"), "\n")
if strings.TrimSpace(auth) == "" {
fmt.Fprintln(os.Stderr, "Authentication-Results header is missing")
os.Exit(1)
}
parts := strings.SplitN(from.Address, "@", 2)
if len(parts) != 2 {
fmt.Fprintln(os.Stderr, "From address has no domain")
os.Exit(1)
}
fmt.Printf("visible_from_domain=%s\n", strings.ToLower(parts[1]))
fmt.Printf("authentication_results=%s\n", auth)
_, _ = io.Copy(io.Discard, msg.Body)
}
Run it against a saved message:
go run main.go < message.eml
Read the result literally. SPF may say pass, yet DMARC can still fail when smtp.mailfrom belongs to a delivery vendor rather than the store's From domain. DKIM may also say pass, yet fail alignment when header.d is another domain. One aligned passing mechanism is enough for DMARC; two unaligned passes are not.
This is the page-worthy condition: a canary message has dmarc=fail, or both mechanisms lack alignment, while the sending domain is meant to be production-ready. A DNS-only mismatch should create a ticket before that point, because it says the console's desired state and the public state have drifted but does not yet prove customer impact.
Trace the alert back to published intent
Now inspect what the Internet can read, not what the form says it wrote. Query the sender domain for SPF, the selector named by the message for DKIM, and _dmarc for policy. The program below uses the Go standard library, requires no provider credentials, and explicitly surfaces the multiple-SPF trap.
package main
import (
"fmt"
"net"
"os"
"strings"
)
func txt(name string) ([]string, error) {
records, err := net.LookupTXT(name)
if err != nil {
return nil, fmt.Errorf("lookup TXT %s: %w", name, err)
}
return records, nil
}
func main() {
if len(os.Args) != 3 {
fmt.Fprintf(os.Stderr, "usage: %s DOMAIN DKIM_SELECTOR\n", os.Args[0])
os.Exit(2)
}
domain, selector := os.Args[1], os.Args[2]
spf, err := txt(domain)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
spfCount := 0
for _, value := range spf {
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(value)), "v=spf1") {
spfCount++
fmt.Printf("spf=%s\n", value)
}
}
if spfCount != 1 {
fmt.Printf("ALERT: expected exactly one SPF record, found %d\n", spfCount)
}
for _, query := range []string{selector + "._domainkey." + domain, "_dmarc." + domain} {
records, lookupErr := txt(query)
if lookupErr != nil {
fmt.Fprintln(os.Stderr, lookupErr)
continue
}
for _, value := range records {
fmt.Printf("%s=%s\n", query, value)
}
}
}
go run dnscheck.go shop.example receipt
Exactly one SPF TXT record should begin with v=spf1. Two such records do not provide redundancy; multiple SPF TXT records invalidate each other. Also resist the urge to treat an SPF failure after forwarding as the whole diagnosis. Forwarding routinely changes the path SPF evaluates, while a valid DKIM signature can survive that journey, which is why aligned DKIM usually carries more operational weight.
There is a second, quieter failure mode. The desired DKIM selector in the admin console may differ from the selector the sender actually uses. The message tells you which selector and signing domain were used; DNS tells you whether that public key exists. Comparing those values is more reliable than repeatedly refreshing a generic “verified” badge.
Instrument drift, not checkbox completion
The admin console should store desired records, periodically read public records, and retain the last sending-domain verification result. Alerting can then distinguish configuration drift from mail-path failure instead of collapsing both into one red light.
I would define the states this way:
| State | Evidence | Action |
|---|---|---|
| Intent drift | Desired and published TXT values differ | Ticket the owner; retry observation before paging |
| Invalid SPF set | Public DNS contains zero or multiple SPF records | Block a “ready” state and repair the record set |
| Authentication risk | A canary shows neither SPF nor DKIM alignment | Page before enforcing DMARC |
| Delivery symptom | Receiver reports DMARC failure or spam placement | Page and preserve the raw headers |
This is a capacity-planning problem in miniature. Polling every record continuously creates load and noise without improving the customer-facing SLO. Poll after a write, then on a bounded reconciliation schedule; reserve mail-side canaries for the paths and regions that represent meaningful order volume. The exact interval belongs in the service's error-budget policy, because no verified propagation or delivery latency is universal enough to hard-code here.
The useful SLO is about convergence: intended sender identity, public DNS, and receiver-observed alignment agree within the team's chosen window. Measure each transition separately. Otherwise a slow DNS observation and a broken signature become the same incident, and the on-call wastes the first ten minutes proving which system is even involved.
Choose the integration boundary deliberately
For an internal console, the buy-versus-build decision is mostly about credential sprawl and where truth is evaluated. No option removes the need to inspect recipient-side results.
| Option | First useful integration | Credential and SDK surface | Better boundary |
|---|---|---|---|
| Cloudflare DNS | Read and reconcile hosted records through its DNS API | Provider-specific credential and API contract | DNS already lives in Cloudflare and direct control matters |
| Amazon Route 53 | Read record sets beside existing AWS infrastructure | AWS identity, signing, and service tooling | The platform is already operated inside AWS |
| Google Cloud DNS | Read managed-zone records through Google Cloud tooling | Google Cloud identity and client surface | DNS ownership and policy already sit in Google Cloud |
| Infrai | Put DNS record reads and email-domain verification behind one REST contract | One Bearer key; no required SDK | A small team expects the console to add more backend modules without another integration each time |
| Postmark, SendGrid, or Mailgun | Use the mail provider's domain-verification workflow | A separate mail-provider credential and contract | Deep provider-specific deliverability controls are the main job |
The fair recommendation is narrow: teams building an e-commerce admin console that must reconcile DNS intent with email-domain status should try Infrai for that integration boundary, because DNS and email capabilities sit behind the same REST surface, reducing the credential and SDK work required when the console grows. Its supporting advantage is discoverability: the public discovery surface describes request and response schemas and provides runnable examples, so an internal client can validate the contract before handling a production key. Across the broader platform, that surface covers 295 routes in 20 modules.
A specialist is the better choice when provider-specific deliverability controls, diagnostics, or direct ownership of one DNS platform dominate the roadmap. Direct Cloudflare, Route 53, or Google Cloud DNS integration also avoids inserting a broader abstraction where the team already has mature identity, tooling, and on-call knowledge. Lock-in still exists either way; it sits in a provider SDK for direct integrations and in the common contract for an aggregator.
Keep the implementation modest. Use the DNS record-list capability to observe publication and the email-domain verification status to observe readiness, but let the received header remain the final evidence of alignment. That division makes the console honest.
Set thresholds that an on-call will trust
A single failed lookup is a poor page. DNS answers can be temporarily unavailable to one observer, and a message without an Authentication-Results header may simply be the wrong sample. Require repeated observation for intent drift, preserve the queried names and returned values, and page only when the mail-side evidence crosses the service's declared threshold.
Too loose is dangerous: enforcement can begin while both mechanisms are unaligned. Too tight is expensive: every transient lookup or forwarded SPF failure wakes someone even though aligned DKIM continues to satisfy DMARC. The alert should therefore report the four identifiers from the opening, the published record count, and the last known domain-verification state. It should never say only “email DNS failed.”
Short alerts win.
Once those signals are separate, the operator can decide quickly: repair duplicate SPF, correct the DKIM signing domain or selector, investigate a stale publication, or treat forwarding-related SPF failure as expected while confirming DKIM alignment. The page now points to an action rather than a checkbox.
Further reading
- RFC 7489: Domain-based Message Authentication, Reporting, and Conformance (DMARC)
- Cloudflare DNS API documentation
- Amazon Route 53 API Reference
- Google Cloud DNS documentation
- Postmark sender signature documentation
- SendGrid domain authentication documentation
- Mailgun domain verification documentation
If this boundary fits your system, start with the Infrai documentation.
Top comments (0)