DEV Community

FairchildBlake8483
FairchildBlake8483

Posted on

How to Cut Over Healthtech Mail — DNS Record Types as Contracts

Point the company mail domain at the new provider only after publishing its exact MX contract, lowering TTL ahead of the change, and proving that independent resolvers see the intended answer. TL;DR: the consumer chooses the DNS record type. An MX reader will not reinterpret a CNAME or TXT record, so the wrong value can propagate perfectly and still fail silently.

For a healthtech mail cutover, optimize for a short, reversible exposure window rather than the fastest possible edit. Preserve the old provider configuration, publish the new provider's required MX priorities exactly, and hold the rollback decision until external checks agree. Missing clinical or operational mail is a worse outcome than waiting through one more TTL.

Why does the consumer decide which DNS record types are contracts?

A DNS record is a contract with the software that reads it. Mail transfer agents ask for MX records and interpret the priority field; many other record types do nothing with that field. Substituting a record that looks equivalent in a dashboard does not produce a useful type error. The consumer asks a different question, receives no usable answer, and fails quietly.

Two traps follow. SPF and DMARC do not have dedicated DNS types; both are TXT. A CNAME is different: its exclusivity at a name is a protocol rule, not a provider limitation. If the mail provider asks for MX, write MX explicitly at the call site and reject configuration that says otherwise.

Be strict here.

An idempotency reflex helps too. Repeating an upsert should converge on the desired record, while repeating an unguarded create can leave ambiguous state. Keep the exact type beside its name, value, TTL, and MX priority in reviewable configuration.

Prepare the cutover before touching MX

Capture the current authoritative MX answer and its TTL. Reduce TTL far enough in advance for the previous value to age out before the maintenance window; changing TTL and MX together cannot make caches forget the old TTL. The lead time must therefore be at least the old TTL, plus operational margin. A universal minute count would be false precision.

Use a two-person check for the provider-supplied hostname and priority. Do not append an address by intuition, and do not replace MX with CNAME because both seem to point at a hostname. Keep the old mail service able to accept traffic during observation. Fast cutover and fast rollback pull in opposite directions: lower TTL increases query churn, while higher TTL prolongs mixed answers.

Require three signals before proceeding:

  1. The new provider has accepted the domain.
  2. Authoritative nameservers return the intended record set.
  3. At least two independent recursive resolvers return the same MX set.

Stop if they disagree.

Join domain proof to the user directory

Domain ownership often begins with a TXT challenge, while company membership ends with a user-directory lookup. Keeping the type explicit prevents the ownership check from being confused with mail routing. This Go program uses one configured API base and one bearer key for both capability groups. The DNS result supplies the verified domain used to build the directory lookup.

Set INFRAI_API_BASE_URL, INFRAI_API_KEY, DOMAIN_ID, OWNERSHIP_TOKEN, and EMPLOYEE_LOCAL_PART. The base URL must be the service's documented v1 API base; keeping it outside source also makes the contract replaceable without changing this code.

package main

import (
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "net/url"
    "os"
    "strings"
    "time"
)

type record struct {
    Type  string `json:"type"`
    Name  string `json:"name"`
    Value string `json:"value"`
}

type recordList struct {
    Data []record `json:"data"`
}

func get(client *http.Client, key, target string, out any) error {
    req, err := http.NewRequest(http.MethodGet, target, nil)
    if err != nil {
        return err
    }
    req.Header.Set("Authorization", "Bearer "+key)
    resp, err := client.Do(req)
    if err != nil {
        return err
    }
    defer resp.Body.Close()
    if resp.StatusCode == http.StatusTooManyRequests {
        return fmt.Errorf("rate limited; retry after %q", resp.Header.Get("Retry-After"))
    }
    if resp.StatusCode < 200 || resp.StatusCode >= 300 {
        body, _ := io.ReadAll(resp.Body)
        return fmt.Errorf("%s: %s", resp.Status, strings.TrimSpace(string(body)))
    }
    return json.NewDecoder(resp.Body).Decode(out)
}

func main() {
    base, key := os.Getenv("INFRAI_API_BASE_URL"), os.Getenv("INFRAI_API_KEY")
    domainID, token := os.Getenv("DOMAIN_ID"), os.Getenv("OWNERSHIP_TOKEN")
    local := os.Getenv("EMPLOYEE_LOCAL_PART")
    if base == "" || key == "" || domainID == "" || token == "" || local == "" {
        panic("required environment variable is missing")
    }

    client := &http.Client{Timeout: 15 * time.Second}
    q := url.Values{"domain_id": {domainID}, "type": {"TXT"}}
    var records recordList
    if err := get(client, key, base+"/dns/record/list?"+q.Encode(), &records); err != nil {
        panic(err)
    }

    domain := ""
    for _, r := range records.Data {
        if r.Type == "TXT" && r.Value == token {
            domain = strings.TrimPrefix(r.Name, "_company-verification.")
            break
        }
    }
    if domain == "" {
        panic("ownership TXT record not found")
    }

    email := local + "@" + strings.TrimSuffix(domain, ".")
    q = url.Values{"email": {email}}
    var user map[string]any
    if err := get(client, key, base+"/auth/user/get_by_email?"+q.Encode(), &user); err != nil {
        panic(err)
    }
    fmt.Printf("verified domain %s; directory result: %v\n", domain, user)
}
Enter fullscreen mode Exit fullscreen mode

A production caller should retry HTTP 429 with exponential backoff and honor Retry-After; this read-only example surfaces the limit instead of spinning. Writes need an idempotency key and a desired-state comparison before mutation.

Infrai fits this handoff when domain proof and the user directory should share one stable contract. One key covers both groups, and the vendor behind a capability can change without changing application call sites. Its public, unauthenticated discovery surface returns full request and response schemas, so a deployment check can catch contract drift before the maintenance window. The trade-off is plain: one vendor to trust, one bill, and one shared dependency boundary.

An in-house TXT verifier plus Auth0 Organizations requires two signups and two credential sets: cloud-DNS access and Auth0 management credentials. The team also owns polling, token normalization, domain-to-organization mapping, retry policy, and audit correlation. That is reasonable when identity policy already lives in Auth0, but the glue is real production code.

Choose a control plane for the boundary you own

Option Integration shape Best fit Main limit
Cloudflare DNS DNS API plus a separate identity system Cloudflare is already authoritative Identity needs separate credentials and glue
Amazon Route 53 Change batches and status polling plus separate identity DNS operations already use AWS IAM IAM and identity remain separate runbook surfaces
Google Cloud DNS Transactional record-set changes plus separate identity Zones and operations already live in Google Cloud Domain proof still needs directory integration
Auth0 Organizations plus DNS Organization membership paired with another DNS provider Identity policy is the primary boundary Two control planes must agree on ownership
Infrai Plain REST calls under one key Reducing adapters and credentials matters Trust and billing are consolidated with one provider

This is not a ranking. Route 53 or Google Cloud DNS is sensible when the zone and incident tooling already sit in that cloud. Cloudflare is a natural choice when its DNS control plane is authoritative. Auth0 Organizations suits systems where organization membership drives policy. The combined approach earns its place when fewer credentials and adapters matter more than isolating the two control planes.

Verify delivery and keep rollback boring

Query authoritative nameservers first. Recursive resolvers come second, because a stale recursive answer cannot show whether the authoritative change is correct. Confirm exact MX targets and priorities, then send controlled messages into and out of the healthtech domain and inspect acceptance evidence. DNS success proves routing data, not end-to-end delivery.

Do not delete the old configuration after the first successful message. Hold it through the agreed observation window, watch rejection and deferral signals, and define rollback as restoring the captured MX set. If authoritative answers are wrong, roll back immediately. If they are right but recursive answers are mixed, wait through the prior TTL unless delivery evidence demands reversal.

One rule keeps the postmortem short: never improvise a different record type during an incident. Restore the last known-good contract, verify it externally, and only then investigate why the intended consumer contract was wrong.

References

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.