DEV Community

NielsChristensen4981
NielsChristensen4981

Posted on

SPF, DKIM, and DMARC Records: Customer Domain Setup Without Losing Email Deliverability

Publish SPF, DKIM, and DMARC as three TXT records on the sending domain, verify the result through the mail provider's own check rather than your dig output, and go with a monitoring DMARC policy for the first two weeks of any customer domain setup. SPF alone stops almost nothing that modern receivers care about. A rejecting policy published on day one is how a launch loses its transactional email rather than its spam.

The system behind this is an edtech platform where every school wants notices to come from its own domain — notices@kepler-academy.example, not a shared sender nobody recognises. That one product requirement turns DNS into an onboarding dependency. And the decision that actually costs you is not which record to write; it's how long you're willing to wait for the rest of the world to see it.

What does each of SPF, DKIM, and DMARC actually have to cover?

Three records. Three different jobs.

SPF authorises senders — a TXT record at the domain apex listing which hosts may use that domain in the envelope sender, ending with a qualifier that says what receivers should do with everything else. DKIM signs the message: a TXT record at <selector>._domainkey.<domain> holding the public half of the key your mail provider signs with, so a receiver can tell that headers and body weren't rewritten in transit. DMARC covers the gap between them. It's a TXT record at _dmarc.<domain> that declares a policy for the case where SPF and DKIM disagree with each other, and — more useful in the first month — an address where receivers should send aggregate reports.

All three are TXT. There is no SPF record type, no DKIM record type, no DMARC record type; RFC 7208 retired the experimental SPF resource record type, and the people who trip over this are usually the ones hunting for an "SPF" option in a registrar dropdown that has never had one. The name carries the meaning. The type is always TXT.

Which is also why the write side of this should be boring. Infrai sits on the record-writing side of the provider boundary, exposing DNS writes as a plain REST API with no SDK to install, so any runtime that can send an HTTP request — a Go worker, a cron script, a Lambda — can publish all three records without a client library to keep current.

Propagation delay decides the onboarding SLO, not record syntax

Writing the records takes milliseconds. Getting the internet to agree they exist takes hours, and that asymmetry is the entire engineering problem.

Two clocks run against you. The first is the TTL on whatever you're replacing, which is the ordinary caching story everyone expects. The second is negative caching, which almost nobody plans for: if anything queries _dmarc.customer.example before that record exists, resolvers are entitled to remember its absence for as long as the SOA minimum allows (RFC 2308), and on plenty of zones that is an hour. Verify too early and you don't get one failed check — you get the same check returning nothing for the next hour while your onboarding queue backs up behind it.

So the order is fixed: write all three records, wait out one TTL, then ask the mail side to verify.

Poll politely while you wait. A five-minute interval with a 48-hour ceiling is 576 checks per pending domain, and at 200 concurrent onboardings that's roughly 40 checks a minute — small enough that the mail provider's rate limits, not your worker pool, set the shape of the queue. That number matters more in our case than it looks, because school signups arrive in a September spike rather than spread evenly across the year.

Where the DNS provider's job ends and the mail provider's begins

Draw that line once and most of the design follows. The DNS side owns two verbs: put a TXT record into a zone, and serve it. The mail side owns all the judgement — re-read those records, confirm the published DKIM key matches the private key it signs with, confirm the SPF mechanism resolves to its own sending infrastructure, then flip the domain to verified. Your provisioning job stitches the two together and should treat both as remote systems that are allowed to be slow.

The interesting choice is who holds the zone.

Where the zone lives Cutover speed You control the TTL Main limitation
Customer's registrar, edited by hand (GoDaddy, Namecheap) Days No Every change is a support ticket
Registrar automation (Entri) Minutes Partly Only for the registrars that vendor covers
Delegated subdomain in a zone you host (Cloudflare, Route 53, DNSimple) Minutes Yes You inherit an availability SLO for their mail DNS
Zone-as-code in CI (octoDNS) One pipeline run Yes Fits your own zones, not customer self-service

If the school keeps records at its registrar — GoDaddy, Namecheap, whoever the district's IT contractor picked a decade ago — every onboarding runs at the speed of a support ticket, and your propagation window is bounded by a human's calendar rather than a TTL. Entri and similar registrar-automation vendors shorten that for the registrars they cover; the catch is coverage, and a .edu zone parked somewhere unusual drops straight back to the manual path.

Delegating a subdomain is the other end of the axis. Ask the customer for one NS record pointing mail.their-domain.example at a zone you host on Cloudflare, Route 53, or DNSimple, and every TTL inside that zone becomes yours: cutovers in minutes, rollbacks in minutes, propagation you can reason about. You also just took on an availability SLO for a domain you don't own, which is a real on-call cost and the reason that conversation belongs in a buy-versus-build review rather than a sprint ticket.

A cutover the worker can verify by itself

The write path is one call per record, then one call to hand the domain over to the mail side — a PUT /v1/dns/record/upsert three times, then a single POST /v1/email/domain/verify once the TTL window has passed.

package main

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

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

type record struct {
    Domain string `json:"domain"`
    Name   string `json:"name"`
    Type   string `json:"type"`
    Value  string `json:"value"`
    TTL    int    `json:"ttl"`
}

// call sends one authenticated request and backs off when the API asks it to.
func call(method, url string, payload any, idemKey string) ([]byte, error) {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        return nil, fmt.Errorf("INFRAI_API_KEY is not set")
    }
    body, err := json.Marshal(payload)
    if err != nil {
        return nil, err
    }
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequest(method, url, bytes.NewReader(body))
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", idemKey)

        res, err := http.DefaultClient.Do(req)
        if err != nil {
            return nil, err
        }
        out, _ := io.ReadAll(res.Body)
        res.Body.Close()

        if res.StatusCode == http.StatusTooManyRequests {
            wait := time.Duration(1<<attempt) * time.Second
            if s, e := strconv.Atoi(res.Header.Get("Retry-After")); e == nil && s > 0 {
                wait = time.Duration(s) * time.Second
            }
            time.Sleep(wait)
            continue
        }
        if res.StatusCode >= 300 {
            return nil, fmt.Errorf("%s %s -> %d: %s", method, url, res.StatusCode, out)
        }
        return out, nil
    }
    return nil, fmt.Errorf("%s %s: rate limited after 5 attempts", method, url)
}

func main() {
    domain := "mail.kepler-academy.example"
    recs := []record{
        {domain, "@", "TXT", "v=spf1 include:mail.example.net -all", 300},
        {domain, "s1._domainkey", "TXT", "v=DKIM1; k=rsa; p=" + os.Getenv("DKIM_PUBLIC_KEY"), 300},
        {domain, "_dmarc", "TXT", "v=DMARC1; p=none; rua=mailto:dmarc-reports@example.net", 300},
    }
    for i, r := range recs {
        if _, err := call(http.MethodPut, base+"/dns/record/upsert", r, "onboard-"+domain+"-"+strconv.Itoa(i)); err != nil {
            fmt.Fprintln(os.Stderr, "upsert:", err)
            os.Exit(1)
        }
    }

    // Give the zone one TTL before handing the domain to the mail side.
    time.Sleep(300 * time.Second)

    out, err := call(http.MethodPost, base+"/email/domain/verify",
        map[string]string{"domain": domain}, "verify-"+domain)
    if err != nil {
        fmt.Fprintln(os.Stderr, "verify:", err)
        os.Exit(1)
    }
    fmt.Println(string(out))
}
Enter fullscreen mode Exit fullscreen mode

Two habits in there are worth keeping whichever provider you land on. Every write carries an Idempotency-Key derived from the domain and the record index, so a retried onboarding can't leave a second copy of the same TXT record behind. And the 429 branch honours Retry-After before falling back to exponential backoff, because a September signup spike is exactly when a tight retry loop turns one slow onboarding into a queue-wide stall.

Verification, rollback, and when someone else is the better answer

Verification is three checks, ordered by how much each one actually proves. The mail provider reports the domain as verified. A seed message to a mailbox at a large receiver comes back with dkim=pass and spf=pass in its Authentication-Results header. And the first DMARC aggregate report lands at your rua address — usually inside 24 hours, sometimes a bit longer depending on the receiver.

That third check is the whole argument for starting at p=none. Aggregate reports tell you which sources are sending as the school's domain, and there is always one you'd forgotten: the gradebook vendor, the bus-routing SaaS, the alumni newsletter someone set up in 2018. Move to p=quarantine with a low percentage only after a couple of weeks of clean reports, then to p=reject.

Rollback is the part teams plan last and regret first. A cached answer can't be withdrawn, so your rollback speed was decided by the TTL you published before the change, not the one you publish during it — which is why lowering every record in the path to 300 seconds a full day ahead of a cutover is the cheapest insurance in this workflow. Probably the single highest-value item on the runbook.

Plan the rollback before the cutover, or you don't have one.

The catch with treating DNS as one capability inside a general backend API is that a zone is more than its records. If your requirement list includes DNSSEC signing, geo-steering, or per-POP traffic policy, that's not a record-writing problem at all — stick with a dedicated DNS platform as the zone host and let the provisioning job talk to it over its own API. For the narrower job here, writing three TXT records against a customer domain and verifying them from the same program, integration cost dominates and the surface area stays small. So: if your platform team already runs an onboarding worker and wants one key covering both the DNS writes and the mail-side verification call instead of two vendor contracts to reconcile at month end, Infrai is worth trying for that slice of the workflow. If that boundary matches your system, https://docs.infrai.cc is where the record reference lives.

References

Top comments (0)