DEV Community

JasperFlint6947
JasperFlint6947

Posted on

5 Ways to Store a Zone Identifier — Safer DNS Record Lookups for Shipment Mail

Bottom line: keep the provider's DNS zone id inside your application, but don't promote it to the primary key of your record operations. The durable key is the intent behind each write — which customer, which mail domain, which record role — and the zone id is a lookup shortcut you should be willing to throw away on any given Tuesday.

That one modeling decision is what keeps a customer's shipment mail flowing while their DNS moves around under you.

The system I have in mind is a freight platform that sends dispatch notifications, proof-of-delivery receipts and monthly invoices from each shipper's own domain, so the customer's brand sits in the From header instead of ours. Before a receiver treats that mail as theirs, three things have to exist in DNS: an SPF record on the sending domain, at least one DKIM selector under _domainkey, and a DMARC policy at _dmarc. Publishing them is a long-running workflow rather than a form submit. Keys rotate, shippers migrate registrars, and sooner or later an IT contractor flattens the apex TXT set and takes SPF down with it.

The runtime is the least interesting part of the question. The same table shape works from a Node.js worker or a Python one; my examples are Python because that's where our eval harness already lives, and I'd rather reuse the harness than build a second one.

1. Key the zone inventory by mail intent, not by what the provider handed back

Write down the identity that the mail flow actually has: customer id, the domain that appears in the From header, the record role (spf, dkim, dmarc), and a selector when the role is DKIM. That tuple is stable for years. It survives a provider migration, a re-created zone, and the day someone decides that invoices should come from a different subdomain than tracking updates.

A zone id is a pointer. Pointers go stale.

They go stale in ways that are specific and annoying, which is why I stopped treating them as identity. Provider zone ids are provider-scoped opaque strings, so the same domain has a different id in every account you hold. Worse, the mapping from name to zone isn't guaranteed to be unique: Route 53 identifies a hosted zone by a caller reference at creation time and will hold two hosted zones with the same domain name in one account, where only the delegated one is authoritative. If the zone id is your primary key, a customer who deletes and re-creates their zone orphans every row you have, and your next lookup writes the right record into the wrong place — a zone nobody resolves.

2. Should the application store the DNS zone id itself, or resolve it on every record operation?

Store it. Resolving on every operation sounds cleaner and behaves worse: it turns each record write into a list-zones call plus a name match, which costs latency, burns provider rate limit on your busiest publishing days, and still leaves you guessing when two zones share a name.

So the inventory row carries the zone id as a cache column, scoped by provider and account, next to the nameservers you actually observed at the apex and the timestamp of the last successful operation on it. When a write comes back with "zone not found", or when the observed nameservers stop matching the provider you have on file, the row is invalid and the resolution path runs again. The zone id is data you can delete and rebuild from DNS; the intent tuple is data you cannot.

The caveat with a cached id is that nothing tells you when it goes bad. A customer can move their domain on a Saturday and your queue keeps writing into a zone that is no longer delegated — every call succeeds, nothing is published. Section 4 is the part that catches this, and it's the part teams skip.

3. Write records by intent and keep the provider adapter boring

The adapter takes a record intent and a zone reference. It never takes one of your row ids, and it never hands you an id you're expected to keep.

from dataclasses import dataclass
from typing import Protocol

@dataclass(frozen=True)
class MailRecord:
    """One published record's identity, independent of any DNS provider."""
    customer_id: str
    from_domain: str            # what receivers see in the From header
    role: str                   # "spf" | "dkim" | "dmarc"
    selector: str | None = None

    @property
    def fqdn(self) -> str:
        if self.role == "dkim":
            return f"{self.selector}._domainkey.{self.from_domain}"
        if self.role == "dmarc":
            return f"_dmarc.{self.from_domain}"
        return self.from_domain

class DnsProvider(Protocol):
    name: str
    def find_zone(self, apex: str) -> str: ...
    def upsert_txt(self, zone_ref: str, fqdn: str, value: str, ttl: int) -> None: ...

def publish(db, provider: DnsProvider, rec: MailRecord, value: str, ttl: int = 3600) -> None:
    # apex_of() cuts the domain at the public suffix, so mail.acme.co.uk -> acme.co.uk
    apex = apex_of(rec.from_domain)
    row = db.zone_binding(rec.customer_id, apex)
    zone_ref = row.zone_id if row and row.provider == provider.name else None
    if zone_ref is None:
        zone_ref = provider.find_zone(apex)
        db.bind_zone(rec.customer_id, apex, provider.name, zone_ref)
    provider.upsert_txt(zone_ref, rec.fqdn, value, ttl)
    db.record_desired_state(rec, value)     # desired, not "published"
Enter fullscreen mode Exit fullscreen mode

Intent-keyed writes are also the only shape that ports across providers. Route 53's change API identifies a record set by name and type rather than by an id, while Cloudflare's DNS API gives every record its own id and expects it on updates. Declarative tooling such as octodns lands in the same place from the other direction: the zone's desired state is the input, and record identity is derived from name and type. Build on top of per-record ids and you've coupled your schema to one vendor's object model; derive the target from the intent tuple and the adapter for the next provider is an afternoon.

4. Reconcile against a resolver, because the receiver's view is the only one that counts

Your table records what you asked for. DNS records what is true. Treat the gap between them as a test suite that runs nightly across every customer domain, with a pass or fail per record role, and you get the same thing an eval harness gives a model change: a number that moves before a customer notices.

import dns.resolver

def observed_txt(fqdn: str) -> list[str]:
    try:
        answer = dns.resolver.resolve(fqdn, "TXT", raise_on_no_answer=False)
    except dns.resolver.NXDOMAIN:
        return []
    if answer.rrset is None:
        return []
    # A TXT record can carry several 255-octet strings; verifiers concatenate them.
    return ["".join(p.decode() for p in r.strings) for r in answer.rrset]

def check(rec: MailRecord, desired: str) -> str:
    values = observed_txt(rec.fqdn)
    if not values:
        return "missing"
    if rec.role == "spf" and sum(v.startswith("v=spf1") for v in values) > 1:
        return "duplicate_spf"          # two SPF records is a permanent error
    return "ok" if desired in values else "drifted"
Enter fullscreen mode Exit fullscreen mode

Four details decide whether that check tells the truth. SPF evaluation is capped at 10 mechanisms that trigger DNS lookups, so a shipper whose apex already carries an accounting suite and a CRM can push past the limit the moment you hand them one more include: — the record parses fine and authentication still fails. A DKIM public key for a 2048-bit pair doesn't fit in a single character string, so it's published as several quoted strings inside one TXT record. Negative answers are cached according to the SOA of the zone, which means a verification probe fired the second after you write is the cheapest way to poison your own cache for an hour. And DMARC only passes when SPF or DKIM authenticates an identifier aligned with the From domain, so a green SPF check on your own bounce domain proves nothing about the customer's mail.

I check TTL on the records I own, too, and keep it low while a rollout is in flight — 300s during onboarding, back to an hour once the policy sticks. Probably overkill for invoices. It costs nothing.

5. Customer-owned or platform-owned zones — decide by who can change NS, not by what's convenient to code

This is the axis that actually shapes the inventory, and it deserves a straight answer rather than a preference.

Zone model Who edits records What you can automate Main trade-off
Customer keeps the zone Their IT or MSP Nothing directly; you verify and nag Every fix is a ticket with someone else's change window
Customer delegates a subdomain (NS) You Full record lifecycle under that subdomain The From header becomes a subdomain, and policy at the parent still governs it
Customer parks the domain with you You Everything, including the apex You now operate their web DNS as well, which is a bigger promise than mail

Delegation is the model I reach for when the shipper's mail is high volume and their DNS change process is slow, because record operations stop being a negotiation. It doesn't support the cases that matter most to compliance-minded customers: plenty of teams cannot hand over NS for anything under their apex, and some registrars' interfaces make subdomain delegation genuinely hard for a non-specialist. For those, CNAME-delegated DKIM selectors plus an SPF include: on their apex leave the zone in their hands and leave you with a verification job instead of a write job — which is the honest version of that arrangement, not a lesser one.

Two operational notes that only show up once you run this for a while. If your DMARC aggregate reports go to a mailbox on your own domain rather than the customer's, the reporting domain has to publish an authorization record under _report._dmarc naming the domain being reported on, or conforming receivers won't send the reports at all — the policy looks fine and the feedback loop is silently empty. And stage the policy: publish at p=none with reporting on, read a week or two of aggregate data until aligned traffic is what you expect, then move to quarantine and reject. A shipper whose warehouse still sends pickup confirmations from a forgotten mail server will find out at reject, and they'll find out in the worst way.

The checklist I'd hand a new engineer on this system is short. Store the intent tuple as the primary key, keep the zone id as a disposable cache column with the observed nameservers beside it, write every record through an adapter that takes a name and a type, verify from a resolver on a schedule rather than after a successful API call, and make the DMARC policy stage an explicit state in the workflow instead of a field somebody edits by hand.

Sources

Top comments (0)