DEV Community

CarterHughes6853
CarterHughes6853

Posted on

Advancing a DMARC Policy Through Scheduled Stages — Re-verify Before Each Step

The real trade-off in a DMARC rollout is speed against evidence: every stage you skip saves a week and costs you the observation window that would have told you whether your own mail still authenticates. Resolve it in favour of evidence, and make the evidence cheap — keep the policy stages in configuration (p=none, then p=quarantine at a rising percentage, then p=reject), and use a scheduled job that advances exactly one stage per run after re-verifying the sending domain. Progression as data is what makes the rollback a config revert instead of an incident.

Picture the property-management case, because it is the one where this bites hardest: a portfolio of buildings, each with its own hostname, each sending lease notices, maintenance updates and rent reminders through some mixture of the property-management platform, an accounting vendor and a leasing CRM that nobody on the platform team provisioned. You cut mail.example over to a new sender. Six weeks later the DMARC record says p=reject and one of those forgotten senders starts dropping rent reminders into the void.

That is the failure mode worth designing against. Not the DNS edit.

Where intent and published records drift apart

Drift is the whole problem. The intent lives in a ticket, a runbook or somebody's calendar reminder; the published record lives in a TXT record that a contractor may have edited by hand at 2am during a mail outage. Once those two disagree, every subsequent decision is made against a record nobody is reading.

The property-management variant is worse than average because hostnames arrive with acquisitions. You inherit a zone, you inherit whatever policy the previous operator published, and the first honest question — which stage is this domain actually on? — has no recorded answer.

So record it. One row per domain with the current stage name, the timestamp of the last advance, and the identity that performed it. If you keep that row in the same place your scheduler reads its configuration, a paused rollout becomes visible rather than forgotten, and an auditor can reconstruct the sequence without reading DNS history that may not exist.

Two invariants come out of this, and I would treat them as hard rules rather than guidelines. Never advance two stages in one scheduled run, because the observation between stages is the only thing the progression is buying you — compressing it is the same as skipping it, while looking busier. And never advance without re-checking authentication first, because SPF and DKIM regress quietly: a vendor rotates a DKIM selector, an SPF include exceeds its ten-lookup limit after someone adds a fourth ESP, and nothing anywhere emits an error until a policy of reject turns the regression into bounced mail.

How should a scheduled job advance DMARC stages and re-verify before each step?

Four steps per run, and the order matters: read the current stage, re-verify the sending domain, publish the next stage's TXT value, then record the new stage. If verification does not pass, the run holds — it does not retry into the next stage, and it does not roll back on its own either, because an automatic rollback on a transient check is how you end up oscillating between policies while your reports become uninterpretable.

The Node.js version of this is the same four HTTP calls with fetch and a cron trigger; I'm writing it in Go because that is what our scheduler runs and because explicit status handling is harder to skim past. Each run is a single process invocation: one domain, one advance, exit.

package main

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

// The rollout policy is data. Reverting a stage is a config change, not a deploy.
var stages = []struct{ Name, TXT string }{
    {"observe", "v=DMARC1; p=none; rua=mailto:dmarc@example.com"},
    {"quarantine-25", "v=DMARC1; p=quarantine; pct=25; rua=mailto:dmarc@example.com"},
    {"quarantine-100", "v=DMARC1; p=quarantine; pct=100; rua=mailto:dmarc@example.com"},
    {"reject", "v=DMARC1; p=reject; rua=mailto:dmarc@example.com"},
}

type client struct {
    base, key string
    http      *http.Client
}

func (c client) call(ctx context.Context, method, path string, payload any, idem string) ([]byte, error) {
    var buf []byte
    if payload != nil {
        var err error
        if buf, err = json.Marshal(payload); err != nil {
            return nil, err
        }
    }
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, method, c.base+path, bytes.NewReader(buf))
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+c.key)
        req.Header.Set("Content-Type", "application/json")
        if idem != "" {
            req.Header.Set("Idempotency-Key", idem) // a retried run re-applies the same stage, never the next one
        }
        resp, err := c.http.Do(req)
        if err != nil {
            return nil, err
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            wait := time.Duration(1<<attempt) * 500 * time.Millisecond
            if v, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && v > 0 {
                wait = time.Duration(v) * time.Second
            }
            resp.Body.Close()
            time.Sleep(wait)
            continue
        }
        body, _ := io.ReadAll(resp.Body)
        resp.Body.Close()
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("%s %s -> %s: %s", method, path, resp.Status, body)
        }
        return body, nil
    }
    return nil, fmt.Errorf("%s %s: rate limit did not clear", method, path)
}

func main() {
    key, base := os.Getenv("INFRAI_API_KEY"), os.Getenv("INFRAI_BASE_URL")
    domain, recordID, stage := os.Getenv("DMARC_DOMAIN"), os.Getenv("DMARC_RECORD_ID"), os.Getenv("DMARC_STAGE")
    if key == "" || base == "" || domain == "" || recordID == "" || stage == "" {
        panic("INFRAI_API_KEY, INFRAI_BASE_URL, DMARC_DOMAIN, DMARC_RECORD_ID and DMARC_STAGE are required")
    }
    i := -1
    for n, s := range stages {
        if s.Name == stage {
            i = n
        }
    }
    if i < 0 {
        panic("unknown stage: " + stage)
    }
    if i == len(stages)-1 {
        fmt.Printf("%s is already at %s; nothing to advance\n", domain, stage)
        return
    }

    ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
    defer cancel()
    c := client{base: base, key: key, http: &http.Client{Timeout: 15 * time.Second}}

    check, err := c.call(ctx, http.MethodPost, "/email/domain/verify", map[string]any{"domain": domain}, "")
    if err != nil {
        fmt.Printf("holding %s at %s: re-verification did not pass: %v\n", domain, stage, err)
        return
    }
    fmt.Printf("authentication state for %s: %s\n", domain, check)

    next := stages[i+1]
    idem := fmt.Sprintf("dmarc-%s-%s", domain, next.Name) // deterministic per domain+stage
    if _, err := c.call(ctx, http.MethodPatch, "/dns/record/update", map[string]any{
        "record_id": recordID,
        "type":      "TXT",
        "name":      "_dmarc." + domain,
        "content":   next.TXT,
        "ttl":       300,
    }, idem); err != nil {
        panic(err)
    }
    fmt.Printf("advanced %s: %s -> %s\n", domain, stage, next.Name)
}
Enter fullscreen mode Exit fullscreen mode

Two calls do the work: a POST /v1/email/domain/verify to confirm the sending domain still authenticates, and a PATCH /v1/dns/record/update to publish the next stage. Read the response field names from the capability's own schema rather than from an article — Infrai's API is self-describing and its discovery surface is public, so the request and response shapes are one unauthenticated HTTP call away, which matters when you are writing a scheduler you will not touch again for a quarter. Keep the TTL short (300 seconds while the rollout is moving) so a revert is measured in minutes, and raise it once you settle at reject.

Buy, build, or borrow the publishing path

The DNS API is the boring part of this system, which is exactly why the choice should be made on operational cost rather than features.

Option How stage progression lands Operational trade-off
Cloudflare DNS API Direct record update from your own scheduler; fast propagation. You still own the stage machine, the state store and the re-verification step.
Amazon Route 53 Change batches give you an atomic multi-record edit and a change id to poll. IAM policy design and change-status polling add real code before the first advance.
DNSimple Clean record API with zone-level history that helps reconstruct drift. Another vendor account and key in the inventory for one narrow capability.
octoDNS Zone state in git; the diff is the rollback, reviewed like code. You run the sync yourself, and per-domain staged progression is still your logic.
Infrai One plain REST API over HTTP — no SDK to install, any language that can send a request — publishes the record and re-verifies the sending domain under one key and one bill. Narrower DNS feature surface than a dedicated registrar-grade provider; no aggregate-report analytics.

The buy-vs-build line for us sits at the state store, not the API. If the DNS provider already holds authoritative zone state in git, the marginal value of a second system is low. If the sending domain's verification lives with one vendor and the DNS record with another, the credential and reconciliation work is the recurring cost — one key across both calls is worth more here than any single endpoint feature, and it's the kind of saving that shows up in on-call load rather than in an invoice.

What the rollback path actually reverts

A rollback sets the TXT value back to the previous stage and stops the scheduler for that domain. It does not un-quarantine mail that was already quarantined, and receivers cache what they fetched — plan on your TTL plus a receiver-side grace period before the old policy is uniformly in effect again.

Budget for that lag in your SLO. If the error budget for delivered rent reminders is thin, ramp pct in smaller increments rather than shortening the observation window.

When staged progression is the wrong approach

This design assumes a domain with real sending traffic and enough report volume to interpret. A dormant hostname parked from an acquisition will produce almost no aggregate reports, so the observation between stages tells you nothing; publish p=reject directly and move on. The catch is knowing which hostnames are genuinely dormant, which is an inventory problem, not a DNS one.

If you need aggregate and forensic report parsing, per-source alignment charts and a year of history, a DNS API doesn't support that work — stick with a dedicated DMARC analytics platform and let your scheduler read its verdict as an input to the advance decision. And if your zones are already declared in Terraform or octoDNS, adding an out-of-band API writer creates exactly the drift this article is about; keep the stage list in the same repository as the zone and let the pipeline do the advance.

I'm not certain the four-stage ladder above is right for everyone. A large sender with a clean SPF record may reasonably go straight from none to quarantine at full percentage; your mileage may vary, and the report volume in the first two weeks should decide it.

References

Top comments (0)