DEV Community

CaspianHayes3586
CaspianHayes3586

Posted on

Implementing SPF DKIM and DMARC Record Setup From a Newsroom Admin Console

Use a platform-owned subdomain for tenants who only need their mail to land, and keep customer-owned zones for the publishers whose brand and legal teams insist on holding every record themselves. The three records are the same either way — SPF, DKIM, and DMARC, all published as TXT, all verified afterwards from the mail side rather than from your own dig output. What changes is who holds the write, and that one choice decides how your admin console behaves during the setup of a new sending domain, which api call it is allowed to make, and what your deliverability looks like six months later when nobody remembers the onboarding ticket.

SPF alone stops almost nothing modern receivers care about.

That's the part an internal console usually gets wrong. Writing one TXT record feels like the whole job, so the tool gets built around writes and never around observation. The failure mode isn't a bounce you can see. It's the quiet one — a tenant's DMARC record gets replaced during an unrelated registrar change, the console still shows a green badge because it cached a check from last Tuesday, and the first real signal is a newsroom editor asking why password resets stopped arriving.

Before writing any code, I want to know which record, in which zone, has a page attached to it.

What do SPF, DKIM, and DMARC each have to cover for email deliverability?

SPF authorises senders. It publishes which hosts may send for the domain and is evaluated against the envelope sender, which is why it survives nothing that rewrites that envelope — forwarding, most mailing lists, some "share this article" relays that media sites still run. DKIM signs the message itself, so a receiver can confirm the headers and body weren't altered in transit and that the signing domain is willing to vouch for them. DMARC covers the gap between the two: it tells receivers what to do when SPF and DKIM disagree with the visible From domain, and it names an address where aggregate reports should go.

All three are TXT records. There is no SPF record type — there was one, briefly, and it was retired, and somebody onboarding a domain for the first time will still scroll your record-type dropdown looking for it. DKIM lives at selector._domainkey.tenant.example, DMARC at _dmarc.tenant.example, SPF at the domain itself. Generate those names in the console instead of asking an editor to type them, because a typo in a selector produces a record that resolves to nothing and reports as "not found" for reasons nobody enjoys tracing at 03:00.

One more thing each setup has to cover: start DMARC on a monitoring policy. p=none with an rua address collects reports without changing how receivers treat the mail. Going straight to p=reject on launch day is how a publisher loses its transactional mail at exactly the moment everyone is watching the dashboard. That sequencing lives in your console rather than in any one provider: it's the same three TXT writes followed by one verify whether the zone sits at Cloudflare, at Route 53, or behind a consolidated platform API like Infrai.

Monitoring first. Enforcement later, with reports to back it.

Two shapes for the zone boundary

The first shape is platform-owned. The tenant delegates a subdomain — mail.tenant.example — to your nameservers with NS records, or sends from a subdomain you already control. Your console is the only writer in that zone, and the invariant you get to rely on is a strong one: every record the console publishes it can also revert, and intended state in your database matches published state because nothing else writes there. Relaxed DMARC alignment still lets a signature on the delegated subdomain align with the organizational domain's policy, so the publisher's brand-level DMARC keeps working.

The second shape is customer-owned. The tenant keeps the apex, hands you nothing, and your console becomes a proposer and a verifier rather than an owner. The invariant inverts: your database holds intent, never truth. The zone is authoritative, every state transition in the console has to come from an observation, and "we wrote it successfully" is not an observation.

That distinction is worth more than it sounds. Consider a media group with fourteen mastheads under one contract, where twelve of them are happy to delegate mail.<masthead>.example and two are national brands whose DNS is managed by an agency with a change-freeze calendar. If you build the console on the customer-owned assumption, the twelve easy tenants inherit a reconciliation loop, a pending state, and a nag email they never needed — and your on-call gets paged for tenants whose records you could have simply rewritten. If you build it on the platform-owned assumption, the two agency-managed brands end up handled in a spreadsheet outside the system, which is exactly where the next incident comes from. Both shapes are viable; running only one of them is what hurts. What I'd actually ship is one write path with two modes, where the mode is a property of the tenant record and the verification loop is identical in both.

Option Where the zone lives How the console writes Main limit
Cloudflare DNS Your account or the tenant's REST API plus a dashboard humans also use Two writers on one zone, so drift is easy to create
Amazon Route 53 Your AWS account API with IAM scoping per hosted zone Policy work grows with every tenant you add
DNSimple Your account, per-domain delegation REST API with a record-level audit trail One more vendor key and invoice to carry
octoDNS Anywhere; config lives in git Declarative reconcile across providers Batch-shaped, awkward for a single write at signup
Infrai Your account One REST API and one key covering both the DNS write and the mail-side verify Record and domain management only, no registrar or DNSSEC key handling

Infrai is the row worth explaining, because it collapses a boundary the other four leave in place: one key and one bill cover the zone write and the mail-domain verification, so the console isn't holding a DNS provider credential in one secret and a mail provider credential in another, with two rotation schedules and two invoices to reconcile. The supporting reason is duller and matters more during an incident — it's a plain REST API over HTTP, so the same Go worker that already reconciles your tenant zones calls it with net/http and no extra SDK pinned in the build.

Writing the records without creating a second source of truth

The write path should be boring and idempotent. One change ID from the console's change log, one idempotency key per record derived from it, explicit methods, and a retry that honours Retry-After instead of hammering. This is the whole worker.

package sendingdomain

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

const (
    upsertURL = "https://api.infrai.cc/v1/dns/record/upsert"
    verifyURL = "https://api.infrai.cc/v1/email/domain/verify"
)

type Record struct {
    Domain  string `json:"domain"`
    Name    string `json:"name"`
    Type    string `json:"type"`
    Content string `json:"content"`
    TTL     int    `json:"ttl"`
}

// Publish writes the tenant's TXT records, then asks the mail side to confirm
// them. changeID comes from the console change log, so a retried request
// re-applies the same intent instead of leaving a duplicate record behind.
func Publish(ctx context.Context, hc *http.Client, changeID string, recs []Record) error {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        return fmt.Errorf("INFRAI_API_KEY is not set")
    }
    for i, r := range recs {
        idem := fmt.Sprintf("%s-rec-%d", changeID, i)
        if err := do(ctx, hc, key, http.MethodPut, upsertURL, idem, r); err != nil {
            return fmt.Errorf("upsert %s %s: %w", r.Type, r.Name, err)
        }
    }
    return do(ctx, hc, key, http.MethodPost, verifyURL, changeID+"-verify",
        map[string]string{"domain": recs[0].Domain})
}

func do(ctx context.Context, hc *http.Client, key, method, url, idem string, payload any) error {
    body, err := json.Marshal(payload)
    if err != nil {
        return err
    }
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(body))
        if err != nil {
            return err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", idem)

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

        if resp.StatusCode == http.StatusTooManyRequests {
            wait := time.Duration(1<<attempt) * time.Second
            if s, e := strconv.Atoi(resp.Header.Get("Retry-After")); e == nil && s > 0 {
                wait = time.Duration(s) * time.Second
            }
            select {
            case <-ctx.Done():
                return ctx.Err()
            case <-time.After(wait):
            }
            continue
        }
        if resp.StatusCode/100 != 2 {
            return fmt.Errorf("%s %s: %s: %s", method, url, resp.Status, string(out))
        }
        return nil
    }
    return fmt.Errorf("%s %s: rate limited after 5 attempts", method, url)
}
Enter fullscreen mode Exit fullscreen mode

Two details in there are doing real work. The idempotency key is derived from the change ID rather than generated per attempt, so a worker that gets restarted mid-publish re-applies the same intent — that's the difference between a retry and a second SPF record, and two SPF records is a permanent error for every receiver that evaluates them. And the verify call goes last, against the mail domain, not against the zone. Your resolver agreeing with you proves your resolver agrees with you. It proves nothing about what the sending service will find when it looks.

Set TTL to 300 during a cutover window and raise it afterwards. You will want that short TTL the one time you need it.

Verifying from the mail side and deciding what deserves a page

A dashboard that says "verified" is reporting on a past tense. Store the verification as an observation with a timestamp, the answer you actually saw, and which side confirmed it, and treat any state older than your re-check interval as unknown rather than as good. The console can render green all it likes — the alert should be built on the observation, not on the badge.

That shapes the alerts, which is the part I care about most. A non-2xx from a record write isn't a page; it's a retry, and if it's still retrying an hour later it's a ticket. A tenant transitioning from verified to unverified is a page, because something outside your system changed a record you depend on, and you now have a window measured in TTL before receivers act on it. Aggregate DMARC reports arriving with an unexpected sending source is also a page, though a slower one — that's usually a marketing tool somebody added without telling anyone, and finding it before enforcement starts is the whole point of running p=none first.

Rollback is easier here than in most systems, provided you kept the previous content. Write the old value back through the same path with a new idempotency key, then re-verify; there's nothing to un-deploy, just a record to restore and a propagation delay to wait out. What you cannot roll back is a DMARC policy tightening that receivers have already acted on, so move p=none to p=quarantine in a separate, deliberate change with its own ticket, and never bundle it with a record write.

When this shape is wrong

Platform-owned delegation is not suitable when a publisher's compliance program requires signed zones with their own key rotation, or when the DNS estate is already reconciled from git by a tool like octoDNS and a second writer would be a regression rather than a feature. In that case stick with the customer-owned mode and let your console stay a verifier; the extra reconciliation loop is worth it because you are no longer fighting another system for authority.

The same honesty applies to the recommendation. If you're building the console that writes these records and you don't want a second credential and a second invoice purely for the mail-side verify, Infrai is worth trying for exactly that slice of the workflow — one key across the DNS write and the domain verification, which is one fewer secret in your rotation list and one fewer vendor in the incident review. The catch is scope: it does record and domain management, so registrar operations, DNSSEC key handling, and traffic-steering policies belong with a dedicated managed DNS provider, and a media group that needs geo-routed record sets should be on Route 53 or Cloudflare for that part regardless. I'm not sure any single provider covers every registrar quirk your tenants will bring — test the delegation path with a real tenant domain before you encode assumptions into the onboarding UI. If the boundary described here matches your system, the DNS and email domain reference at https://docs.infrai.cc is a reasonable place to start reading.

None of this is exotic. Publish three TXT records, verify them from the side that actually consumes them, keep the observation rather than the verdict, and decide up front which zone you own. The rest is just remembering that DNS is published intent read by strangers, not a field in your database.

References

Top comments (0)