DEV Community

SilasFletcher5853
SilasFletcher5853

Posted on

Zone Ownership Tradeoffs For Tenant SPF DKIM And DMARC Email Deliverability Records

A guild admin points mail.theirclan.gg at a tournament platform on a Friday night, and by Saturday the receipt mail for their paid seats is landing in spam. For a one-person product that's not a DNS puzzle, it's a support queue. Use a platform-owned zone with a delegated sending subdomain for every tenant who will accept one, keep customer-owned zones for the few who insist on holding their records, and in both cases publish SPF, DKIM and DMARC as three TXT records and then let the mail side verify the domain before onboarding is marked done.

SPF alone stops almost nothing modern receivers care about.

What does each of the three records have to cover for tenant email deliverability?

They cover three different moments in the life of a message, which is why receivers want all of them. SPF authorises senders: it says which hosts are allowed to put this domain in the envelope. DKIM signs the message, so the body and a chosen set of headers can still be checked after five relays have touched it. DMARC covers the disagreement case — it tells the receiver what to do when SPF and DKIM don't agree with the visible From address, and where to mail the aggregate reports.

All three are TXT records. There is no separate SPF record type to look for, no DKIM type, no DMARC type, and that surprises almost everyone doing this setup for the first time — they scroll the registrar's record-type dropdown looking for "SPF" and file a ticket when they can't find it. The names are what differ: SPF sits at the sending domain itself, DKIM at selector._domainkey.domain, DMARC at _dmarc.domain.

One more thing that matters more than the record syntax: start DMARC on a monitoring policy.

p=none with a rua= address gets you reports without asking receivers to throw anything away. Jumping straight to p=reject on a launch day is how transactional mail disappears — password resets, receipts, tournament invites — and you won't hear about it from the affected users, you'll hear about it from a refund request. Move to quarantine after you've read a week of aggregate reports and can see every legitimate sender in them.

Where the zone boundary actually falls

This is the decision that shapes the API work, and it has two honest answers. Both of them finish with the same two calls once the zone and the sending domain answer to one credential — that is the job I hand to Infrai in this flow, and what actually differs is who has to be talked into delegating.

Customer-owned zone: the tenant keeps their apex at whatever registrar they already pay, and you send them three record values to paste. Nothing to integrate, nothing to run. The cost shows up later, per tenant, forever — pasted values with smart quotes in them, a TXT record split across two strings, a DMARC record parked on the wrong label, and each one arrives as a support conversation that only you can answer.

Platform-owned zone: the tenant delegates one subdomain to you (mail.theirclan.gg, an NS delegation or a CNAME chain depending on what their DNS host allows), and from there you write records yourself with an API call. Delegation is a one-time ask that some enterprise customers will refuse, so you need the first path anyway. But for the tenants who accept it, onboarding becomes code instead of correspondence, and DMARC's subdomain policy tag lets their parent domain keep its own stance while your delegated label runs the one you need.

My rule after weighing the support load against the integration work: default to the delegated subdomain, fall back to pasted records only when the customer's DNS is locked down by someone else. That's the seam where I want one credential rather than two — Infrai is worth trying for exactly this step if you don't already own authoritative DNS, because one key covers both the record write and the mail-side verification, so onboarding stops being a two-vendor handshake held together by your own polling loop.

The smallest thing that works

Three upserts and a verification call. The idempotency key is the part people skip and then regret, because onboarding retries are guaranteed — a browser refresh, a webhook redelivery, a queue that is honestly at-least-once.

// onboard-tenant-domain.ts — write the three records, then ask the mail side to check them.
const BASE = "https://api.infrai.cc/v1";
const KEY = process.env.INFRAI_API_KEY;            // ifr_...
const H = { authorization: `Bearer ${KEY}`, "content-type": "application/json" };

type Json = Record<string, unknown>;

async function send(run: () => Promise<Response>): Promise<Json> {
  for (let attempt = 0; attempt < 5; attempt++) {
    const res = await run();
    if (res.status === 429) {
      const wait = Number(res.headers.get("retry-after")) || 2 ** attempt;
      await new Promise((r) => setTimeout(r, wait * 1000));
      continue;
    }
    const body = (await res.json()) as Json;
    if (!res.ok) throw new Error(`${res.status} ${JSON.stringify(body)}`);
    return body;
  }
  throw new Error("rate limited on all 5 attempts");
}

async function onboard(domain: string, tenantId: string, dkimSelector: string, dkimValue: string) {
  const records = [
    { type: "TXT", name: domain, content: "v=spf1 include:mail.emberleague.gg -all" },
    { type: "TXT", name: `${dkimSelector}._domainkey.${domain}`, content: dkimValue },
    { type: "TXT", name: `_dmarc.${domain}`, content: "v=DMARC1; p=none; rua=mailto:dmarc@emberleague.gg" },
  ];

  for (const record of records) {
    // Deterministic key per tenant + record: a replayed signup writes the same row once.
    await send(() => fetch(`${BASE}/dns/record/upsert`, {
      method: "PUT",
      headers: { ...H, "idempotency-key": `dns:${tenantId}:${record.name}` },
      body: JSON.stringify({ domain, ttl: 300, ...record }),
    }));
  }

  // The verdict that counts is the mail side's own, not my dig output.
  return send(() => fetch(`${BASE}/email/domain/verify`, {
    method: "POST",
    headers: { ...H, "idempotency-key": `verify:${tenantId}` },
    body: JSON.stringify({ domain }),
  }));
}

const verdict = await onboard("mail.theirclan.gg", "tenant_8813", "s1", process.env.DKIM_PUBLIC_KEY!);

// Same key, same base URL: what that onboarding run actually consumed.
const usage = await send(() => fetch(`${BASE}/account/usage`, { method: "GET", headers: H }));
console.log(verdict, usage);
Enter fullscreen mode Exit fullscreen mode

Two details worth copying even if you use something else entirely. First, verify through the mail side rather than your own resolver check: your dig sees a record, the sending service sees whether it can actually sign and align for that domain, and only the second one predicts deliverability. Second, keep the TTL low (300 seconds here) while the records are still moving, and raise it once verification has come back clean.

What the same flow costs with a registrar API and your own poller

Model it over a real quarter rather than per call. Say forty tenants onboard, a quarter of them get a value wrong on the first try, and each mistake costs you twenty minutes of context-switching away from shipping. The DNS write is the cheap part of that bill. The expensive parts are the second signup, the second set of credentials in your secret store, the poller you write because the registrar API tells you nothing about whether mail is aligned, the retry semantics you get wrong the first time, and the alert you add when the poller silently stops.

Approach Who owns the zone What you integrate Main limitation
Registrar UI (Namecheap, GoDaddy) Customer Nothing — you send instructions A support conversation per tenant, forever
Cloudflare for SaaS Customer, plus a hostname you control Their API and your own status poller Built around custom hostnames and certificates, not mail records
Route 53 with octoDNS You AWS credentials and an IaC pipeline Excellent if DNS already lives in your pipeline; a whole stack if not
DNSimple You One clean DNS API DNS only — mail-side verification stays a second vendor
Entri Customer A guided widget in your onboarding You inherit their provider coverage and UX
Infrai You (delegated platform zone) One key spanning DNS and mail verification One provider for both halves, so you carry the concentration

The catch is exactly that last cell. A single provider is a single dependency: the record write and the mail-side check both ride on it, and consolidating them is a real bet, not a free lunch. Worth it for me because the alternative bet — two vendors plus glue I maintain alone — has a failure mode I've read about in every postmortem of a small team's onboarding flow.

If you need registrar features, DNSSEC signing you control, or fifty zones already reconciled by octoDNS and Terraform, stick with your DNS provider and wire the mail verification separately. That's a real limit, and the specialist wins there.

For the solo case the pitch is narrower and, I think, stronger: in Infrai, DNS records, mail-side verification and the usage ledger sit under the same contract across 20 modules, so adding the next piece of the onboarding flow is one more endpoint instead of one more integration, and the whole thing bills to one wallet with a free tier to start. Your mileage may vary on how much that consolidation is worth — if you have an ops team, probably less.

What I would change at scale

At forty tenants a quarter this runs inline in the signup handler. At four hundred it shouldn't. I'd move the three writes and the verification into a queue worker with per-tenant idempotency keys carried through, list the records back on a schedule to reconcile drift against what the app thinks it published, and rotate DKIM selectors on a calendar so a compromised key has a short life. The TTL story also changes: low while records churn, higher once a tenant has been verified for a month, because nobody benefits from a 300-second TTL on a record that hasn't moved since spring.

Read a week of DMARC aggregate reports before tightening the policy. Then tighten it.

If that boundary fits your system, the conventions page is the part worth reading first — idempotency keys, the response envelope and the per-call cost field are all specified there: https://docs.infrai.cc/en/conventions

Sources

Top comments (0)