DEV Community

PerNilsson3147
PerNilsson3147

Posted on

DNS Plus Mail Explained: An Internal Endpoint Sets Each Tenant Domain

Give each logistics tenant one control-plane operation that stores the desired mail configuration, publishes the required DNS changes, checks the public result, and activates sending only after the result matches the intent. The endpoint should start a reconciliation process rather than hold an HTTP connection open while DNS changes propagate.

TL;DR: return an operation ID, make every step retryable, and treat the public DNS view as evidence. A successful write is not proof that the record is visible or correct.

For a tenant such as north-yard, the application can derive north-yard.mail.example.test from approved input and keep one durable record of what should exist. This keeps the client contract small without pretending the work is atomic.

How can one internal endpoint set up a sending domain?

The useful promise is narrow: accept a validated tenant and domain request exactly once, converge the external state toward a stored plan, then expose the current phase. It should not promise immediate activation. DNS publication and mail-domain validation cross system boundaries, so the operation has intermediate states even when the caller sees one endpoint.

I would model five phases: planned, publishing, checking, active, and blocked. Those are application states, not claims about any particular DNS or mail service. The distinction matters because an HTTP 200 from a downstream write only says that write was accepted; the reconciler still needs to read the externally visible state and compare it with the plan.

The data flow is straightforward. The API validates tenant ownership, derives the subdomain, stores an immutable desired record set, and queues reconciliation. A worker publishes records through generic adapters. It then queries authoritative or public DNS, normalizes the answer, compares it with the desired set, and asks the mail adapter for its validation result. Only a full match moves the tenant to active.

No guesswork.

There is a real trade-off here. Asynchronous setup adds a status resource, a queue, and operator-visible state, so it is a poor fit for a small system where a human intentionally configures one stable domain and can verify it manually. It earns that complexity when tenant domains are created repeatedly and unattended activation would otherwise depend on several unrelated callbacks. I prefer the extra state in that case because it makes partial progress inspectable, but the limitation is clear: this is a control-plane workflow, not a shortcut to instantaneous DNS convergence.

A minimal TypeScript implementation

The example below keeps the provider boundaries explicit. It omits transport-specific credentials and uses reserved example names, but the orchestration is runnable TypeScript once the three interfaces are backed by infrastructure adapters.

type Phase = "planned" | "publishing" | "checking" | "active" | "blocked";

type DnsRecord = {
  name: string;
  type: "TXT" | "CNAME";
  value: string;
};

type Setup = {
  operationId: string;
  tenantId: string;
  domain: string;
  desired: DnsRecord[];
  phase: Phase;
  attempts: number;
  lastError?: string;
};

interface SetupStore {
  createOnce(idempotencyKey: string, setup: Setup): Promise<Setup>;
  get(operationId: string): Promise<Setup>;
  save(setup: Setup): Promise<void>;
}

interface DnsAdapter {
  publish(records: DnsRecord[]): Promise<void>;
  resolve(records: DnsRecord[]): Promise<DnsRecord[]>;
}

interface MailAdapter {
  desiredRecords(domain: string): Promise<DnsRecord[]>;
  isValidated(domain: string): Promise<boolean>;
}

const normalize = (record: DnsRecord): string =>
  `${record.type}|${record.name.toLowerCase()}|${record.value.trim()}`;

const sameRecords = (desired: DnsRecord[], observed: DnsRecord[]): boolean => {
  const left = desired.map(normalize).sort();
  const right = observed.map(normalize).sort();
  return left.length === right.length && left.every((value, i) => value === right[i]);
};

export async function requestTenantMailSetup(
  tenantId: string,
  idempotencyKey: string,
  store: SetupStore,
  mail: MailAdapter,
): Promise<Setup> {
  if (!/^[a-z0-9-]{1,40}$/.test(tenantId)) throw new Error("invalid tenant ID");

  const domain = `${tenantId}.mail.example.test`;
  const desired = await mail.desiredRecords(domain);

  return store.createOnce(idempotencyKey, {
    operationId: crypto.randomUUID(),
    tenantId,
    domain,
    desired,
    phase: "planned",
    attempts: 0,
  });
}

export async function reconcile(
  operationId: string,
  store: SetupStore,
  dns: DnsAdapter,
  mail: MailAdapter,
): Promise<void> {
  const setup = await store.get(operationId);
  if (setup.phase === "active") return;

  try {
    setup.attempts += 1;
    setup.phase = "publishing";
    await store.save(setup);
    await dns.publish(setup.desired);

    setup.phase = "checking";
    await store.save(setup);
    const observed = await dns.resolve(setup.desired);
    const dnsMatches = sameRecords(setup.desired, observed);
    const mailValidated = dnsMatches && await mail.isValidated(setup.domain);

    setup.phase = dnsMatches && mailValidated ? "active" : "checking";
    setup.lastError = undefined;
  } catch (error) {
    setup.phase = "blocked";
    setup.lastError = error instanceof Error ? error.message : "unknown error";
  }

  await store.save(setup);
}
Enter fullscreen mode Exit fullscreen mode

The client submits one request and receives the operation ID plus the current phase. A status read can return the desired and observed sets, but credentials and raw downstream responses should stay out of that payload. The worker may run again after a delay; publish must therefore behave as an upsert, and createOnce must bind the caller's idempotency key to the original operation.

Drift is the real failure mode

The tempting implementation stops after publishing. That leaves a gap between intent and reality: a record can be changed outside the application, an earlier value can remain visible, or a later tenant update can race an older job. The system needs three separate snapshots: the immutable plan for this operation, the last observed public answer, and the current lifecycle state.

Compare normalized record tuples, not presentation strings. Case differences in names and harmless formatting differences should not create false drift, while missing, extra, or changed values should. Be careful with the word “extra,” though. A shared owner name may contain records that belong to another workflow; the adapter needs an explicit ownership rule before it deletes anything.

Activation is a policy decision backed by observations. For this logistics example, the conservative rule is that every managed record matches and the mail-side validation reports success. If either check later changes, stop onboarding new traffic for that tenant and reconcile again. The article does not assume that DNS changes are instantaneous or that one observation proves permanent correctness.

Observation State Next action
Managed tuples differ checking Save the diff and retry later
DNS matches; mail validation waits checking Recheck without republishing unchanged data
Both checks agree active Permit the tenant's sending workflow

One green check is insufficient.

DMARC adds another reason to keep policy separate from publication. RFC 7489 defines a DNS-published policy and aggregate and failure reporting mechanisms for message authentication. A provisioning service can carry the intended DMARC record in its desired set, but choosing enforcement policy and report destinations belongs to the organization operating the mail program. Do not silently strengthen that policy inside a generic onboarding endpoint.

Make retries cheap and mistakes visible

Queue work by operation ID, cap concurrent reconciliations, and use delayed retries rather than tight polling. That controls query volume and keeps a slow external convergence from consuming request workers. The cost-sensitive move is to query only names in the desired set and to stop polling after a bounded window; a scheduled audit can pick up long-lived drift later.

Observability should answer a few concrete questions. How long has this operation occupied its current phase? Which desired tuple differs from the observed tuple? How many attempts have run? Was the latest failure validation, publication, resolution, or mail-side checking? Record those as structured fields with the operation and tenant IDs. Avoid putting complete record values into broad logs because they may contain verification material or reporting addresses.

Retries need classification. Invalid tenant input and an unauthorized parent domain are terminal. A temporarily unavailable adapter is retryable. A persistent mismatch is neither an exception nor success; it is a visible checking state with the diff attached. This is one place where a short state machine pays for itself.

Testing follows the same boundary. Unit-test normalization, exact-set comparison, and state transitions with deterministic fixtures. Contract-test each adapter against a disposable zone and mail account. Before deployment, run the reconciler in read-only mode against staged records so it can report a diff without publishing or deleting anything.

The operating rule I would ship is compact: keep the endpoint boring by validating, persisting, enqueueing, and returning; keep the worker suspicious by publishing idempotently, reading back independently, comparing exact owned state, and activating only after both DNS and mail checks agree. On each deployment, verify that the queue can resume abandoned operations, old jobs cannot overwrite a newer plan, and operators can see the last desired-versus-observed diff. Review the bounded retry window and audit cadence against actual onboarding latency and query volume rather than hard-coding an assumption.

This design gives every tenant an automatic subdomain while preserving the distinction that matters most: requested configuration is intent; independently observed records are evidence.

Further reading

Top comments (0)