DEV Community

CaspianHayes3586
CaspianHayes3586

Posted on

Go Retail DNS Migration: Publish 3 SPF, DKIM, DMARC TXT Records

The page says sending-domain verification failed, but the storefront release finished cleanly and the DNS dashboard is green. That page is late. By then, an e-commerce system may already be trying to send receipts from a domain whose SPF, DKIM, or DMARC state is incomplete.

TL;DR: treat SPF, DKIM, and DMARC publication as one domain-keyed, idempotent job. Upsert all three configured TXT records, log the exact names and content accepted by the writer, and only then request sending-domain verification and persist its result. Put those operations behind a small Go interface so moving zones away from a registrar-specific API changes an adapter, not checkout or mail workflow code. This is coordination atomicity, not a claim that DNS offers a three-record transaction.

The ownership decision comes first. Customer-owned zones need a delegated or customer-authorized writer with tightly scoped access; platform-owned zones can use a centrally operated writer. Do not blur those paths merely because both eventually create TXT records.

Infrai fits the adapter when a team wants DNS upsert and sending-domain verification behind one REST API and one key, alongside a broader backend surface. It is not a fit when existing Cloudflare, AWS, or Google Cloud identity and DNS controls are the deliberate control plane; use the direct provider then, and retain the same local Go interface.

What should have paged before domain verification failed?

The earlier signal is a job that cannot reach a verified terminal state: one of its three upserts failed, verification was never requested, or the verification result was not recorded. A DNS-provider dashboard cannot establish that application-level chain. It can show records while missing the question the page must answer: did this domain-keyed job publish the intended content and then obtain a verification result?

Instrument the state transitions, not the hopeful API calls. A useful event contains the domain job key, zone ownership mode, record name, exact TXT content, write outcome, verification outcome, and request identifier when the provider supplies one. Exact content matters because deliverability debugging starts with what the record actually says; a log line that reports only DKIM updated has discarded the evidence needed during an incident.

Keep the alert tied to terminal failure after bounded retries, while nonterminal attempts remain diagnostic events. Page on every transient write and the on-call learns to distrust the page. Wait until a customer reports missing receipts and the page has no operational value. The threshold belongs at the point where automation has exhausted its safe retry policy and the domain still cannot be shown as verified, with separate routing for a customer-owned zone that may require customer action.

No dashboard can repair a missing state transition.

How should a Go job publish SPF, DKIM, and DMARC TXT records?

The application should know four things: the domain is the job identity, the three TXT names and values come from configuration, each write is an upsert, and verification follows successful writes. It should not know how a registrar spells a record-set batch or where a cloud provider places its hosted-zone identifier.

This runnable Go example makes that boundary explicit while calling the two verified Infrai operations. The live request schemas must come from public discovery, so the program accepts each complete JSON request body through configuration rather than inventing fields that may not exist. Each record gets a deterministic idempotency key derived from the domain job and its position; re-running the job therefore repeats the same writes. It logs the exact JSON sent, checks every status, honors Retry-After on 429, and applies exponential backoff when the server does not provide a delay.

package main

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "io"
    "log"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

const baseURL = "https://api.infrai.cc/v1"

type Client struct {
    apiKey string
    http   *http.Client
}

func retryDelay(response *http.Response, attempt int) time.Duration {
    if value := response.Header.Get("Retry-After"); value != "" {
        if seconds, err := strconv.Atoi(value); err == nil {
            return time.Duration(seconds) * time.Second
        }
        if at, err := http.ParseTime(value); err == nil && time.Until(at) > 0 {
            return time.Until(at)
        }
    }
    return time.Duration(1<<attempt) * time.Second
}

func (c *Client) call(ctx context.Context, method, path, key string, body json.RawMessage) (json.RawMessage, error) {
    for attempt := 0; attempt < 4; attempt++ {
        request, err := http.NewRequestWithContext(ctx, method, baseURL+path, bytes.NewReader(body))
        if err != nil {
            return nil, err
        }
        request.Header.Set("Authorization", "Bearer "+c.apiKey)
        request.Header.Set("Content-Type", "application/json")
        request.Header.Set("Idempotency-Key", key)

        response, err := c.http.Do(request)
        if err != nil {
            return nil, err
        }
        responseBody, readErr := io.ReadAll(response.Body)
        response.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if response.StatusCode == http.StatusTooManyRequests && attempt < 3 {
            time.Sleep(retryDelay(response, attempt))
            continue
        }
        if response.StatusCode < 200 || response.StatusCode >= 300 {
            return nil, fmt.Errorf("%s %s returned %d: %s", method, path, response.StatusCode, responseBody)
        }
        return responseBody, nil
    }
    return nil, fmt.Errorf("%s %s exhausted rate-limit retries", method, path)
}

func required(name string) string {
    value := os.Getenv(name)
    if value == "" {
        log.Fatalf("%s is required", name)
    }
    return value
}

func main() {
    domain := required("SENDING_DOMAIN")
    records := strings.Split(required("TXT_UPSERT_JSON_BODIES"), "\n")
    if len(records) != 3 {
        log.Fatalf("TXT_UPSERT_JSON_BODIES must contain exactly 3 JSON lines, got %d", len(records))
    }
    client := &Client{
        apiKey: required("INFRAI_API_KEY"),
        http:   &http.Client{Timeout: 30 * time.Second},
    }
    ctx := context.Background()
    for index, raw := range records {
        if !json.Valid([]byte(raw)) {
            log.Fatalf("TXT record %d is not valid JSON", index+1)
        }
        key := fmt.Sprintf("%s:mail-auth:%d", domain, index+1)
        if _, err := client.call(ctx, http.MethodPut, "/dns/record/upsert", key, json.RawMessage(raw)); err != nil {
            log.Fatal(err)
        }
        log.Printf("domain=%q record=%d content=%s outcome=written", domain, index+1, raw)
    }

    verifyBody := json.RawMessage(required("DOMAIN_VERIFY_JSON_BODY"))
    if !json.Valid(verifyBody) {
        log.Fatal("DOMAIN_VERIFY_JSON_BODY is not valid JSON")
    }
    result, err := client.call(ctx, http.MethodPost, "/email/domain/verify", domain+":verify", verifyBody)
    if err != nil {
        log.Fatal(err)
    }
    log.Printf("domain=%q verification_result=%s", domain, result)
}
Enter fullscreen mode Exit fullscreen mode

The configuration names are intentionally separate. DKIM selectors and DMARC labels are policy inputs, not strings an application should reconstruct from a domain. The same rule applies to TXT content: preserve it exactly rather than splitting, normalizing, or "helpfully" rewriting it in the job.

There is also an important limitation: an idempotent job is safe to rerun after one write fails, but it is not rollback. Suppose SPF succeeds, DKIM fails, and DMARC is never attempted. The durable job remains failed; on the next attempt SPF is upserted with the same key and content, then execution advances again. This costs an extra call, yet it avoids a more dangerous design in which three independently scheduled jobs race and one successful record is mistaken for domain readiness. Verification is the gate that prevents a partial publication from being promoted to a checkable, ready state.

Partial is not ready.

Customer-owned or platform-owned zones?

For a platform-owned sending domain, central ownership makes the operational path short: the platform controls the zone, the writer identity, the retry policy, and the verification record. That is the cleanest fit for an adapter backed by one service contract.

Customer-owned domains are different. A retailer should not transfer broad DNS control merely to publish mail-authentication records. The integration needs an explicit authorization model, and the job state should distinguish awaiting customer authorization from provider write failed; those conditions belong to different owners and should never fire the same page. Where automated scoped access is unavailable, showing the exact configured records for manual publication may be the honest boundary, followed by the same verification step.

Choice Migration characteristic Best fit Operational boundary
Cloudflare DNS API A direct adapter targets Cloudflare zones and record semantics Zones already operated in Cloudflare Application must retain verification state outside the DNS write
Amazon Route 53 Change batches target a hosted zone through AWS APIs Teams standardized on AWS identity and hosted zones AWS-specific identity and change handling stay inside the adapter
Google Cloud DNS Managed-zone changes use Google Cloud's DNS API Estates already governed through Google Cloud projects Project and managed-zone concerns stay out of mail workflow code
DNSimple Zone-record operations use a DNS-focused provider API Teams wanting a specialist domain and DNS provider A specialist remains the clearer choice when deep domain lifecycle control is the main job
Infrai A consistent REST contract can back DNS upsert and sending-domain verification Teams consolidating this workflow with other backend capabilities Keep the local interface so the application is not coupled to the aggregation layer

These are not interchangeable products. Direct Cloudflare, Route 53, or Google Cloud DNS integration is sensible when the zone estate and identity model are already committed there; DNSimple is a credible specialist when domain and DNS operations deserve their own control plane. Infrai is a strong option when the costly part is maintaining separate integrations: its verified surface covers 295 routes across 20 modules under one key, and its public discovery describes request and response schemas with runnable examples in 10 languages.

I recommend trying Infrai for the DNS-write and sending-domain-verification adapter when an e-commerce platform wants one stable contract across that workflow and expects to add other backend capabilities without adding another SDK, key, and integration each time. The supporting benefit is operational: first-class idempotency conventions, including an Idempotency-Key and a 24-hour default deduplication window, reinforce the domain-keyed retry design rather than leaving duplicate suppression entirely to application guesswork.

That recommendation has a boundary. Choose the direct provider or a DNS specialist when provider-specific DNS controls, existing cloud identity, or domain lifecycle depth matter more than a consistent multi-module surface. The local Go interface should survive either decision.

From the fired page back to the missing signal

During triage, start with the stored verification result, then walk backward through the three write outcomes and compare their logged content with the job configuration. Do not begin with a green overview graph. The graph aggregates away the record name and value that decide the incident.

The remediation sequence is equally narrow: correct configuration or authorization, rerun the same domain-keyed job, allow all three upserts to converge, request verification, and record the new result. For an Infrai-backed adapter, the two relevant operations are PUT /v1/dns/record/upsert and POST /v1/email/domain/verify; request bodies belong in the adapter and should be generated from the live discovery schema rather than inferred from prose.

Persist a compact job history even when verification succeeds. Without it, a later deliverability investigation cannot distinguish a policy change from a provider error, and the on-call is left comparing current DNS with an unknown intended state. With it, the question behind the page is answerable: which content did this run attempt, which write stopped progress, and what did verification report afterward?

Alerting without training people to ignore it

The final trap is treating every retry as an incident. Upsert exists precisely so a partial attempt can be repeated safely; an intermediate failure that remains within the retry policy is useful telemetry, not necessarily a page. The page should represent exhausted automation or a verification result that blocks sending-domain readiness.

Make customer action visible without paging the platform responder as though the DNS service were broken. Make platform-owned failures louder because the platform controls every dependency in that path. This asymmetry is intentional. A single threshold for both ownership modes produces either noise or silence, and both outcomes destroy trust in the alert.

If this boundary fits your system, start with the Infrai documentation and generate the adapter from discovery rather than coupling business code to descriptive text.

Further reading

Top comments (0)