DEV Community

VaughnKnight3189
VaughnKnight3189

Posted on

One Internal Endpoint Explained: End-to-End DNS for a Sending Domain

Provision a tenant's sending domain through one internal endpoint, but do not treat the endpoint's success response as proof that mail is ready. The deciding constraint is evidence: customer support mail should go live only after the expected DNS policy can be observed and its meaning evaluated.

TL;DR: make the request idempotent, return the records that the tenant must publish, and keep the domain in a pending state until an independent DNS read confirms the policy. Store every observation with its timestamp and result. Your control plane then answers a much more useful question than "did the setup call work?": "what did we observe before we allowed this tenant to send?"

That distinction matters in customer support. A domain may be requested once, edited by a tenant administrator later, cached by resolvers, or queried while a previous answer is still visible. A green HTTP response cannot settle any of those conditions. DNS evidence can.

That is the gate.

From a write operation to an evidence trail

The naive mental model is short: receive support.example, save configuration, return 200, done. It makes the synchronous path look tidy. It also collapses intent, publication, observation, and authorization into one event even though they happen at different times and may be controlled by different people.

Use a before/after model instead. Before, the endpoint means "configuration was accepted." After, it means "a durable enrollment exists, these are the required DNS records, and this is the latest independently observed state." Sending permission remains a separate decision. Picture the flow as a diagram in words: tenant request -> normalized enrollment -> required record set -> tenant publishes DNS -> verifier reads DNS -> evidence is stored -> policy gate enables sending. The arrow after publication is deliberately asynchronous. DNS is external state, so forcing the entire chain into one long request creates a timeout contract rather than a readiness contract. For an acme-help.example support team, that means the UI can immediately show a precise publication task while the control plane waits for proof, instead of holding an HTTP connection open and later pretending that a timeout says anything about the tenant's configuration.

Keep three states distinct: pending, ready, and blocked. Pending means the system has not yet collected sufficient evidence. Ready means the observed policy matches the enrollment's requirements. Blocked means an observation produced a definite mismatch that needs action. A lookup timeout is not automatically a mismatch; it is an inconclusive observation, and preserving that distinction keeps alerts honest.

This is the first operational payoff. Metrics can count transitions by result, logs can carry an enrollment ID, and an alert can focus on domains stuck pending beyond your service objective. The dashboard no longer needs to infer readiness from setup-call latency.

How does one internal endpoint set up a sending domain?

The endpoint should create or retrieve an enrollment and return a declarative plan. It should not promise immediate DNS convergence. A stable idempotency key prevents a retried customer-support workflow from creating multiple enrollments, while a stable enrollment ID connects API logs, DNS observations, and the eventual sending decision.

Keep those facts separate.

Here is a compact TypeScript contract and handler. The DNS name and value are examples for the fictional tenant acme-help.example; the important part is the boundary between requested state and observed state.

type EnrollmentState = "pending" | "ready" | "blocked";

type RequiredTxtRecord = {
  kind: "TXT";
  name: string;
  value: string;
  purpose: "dmarc-policy";
};

type SenderEnrollment = {
  id: string;
  tenantId: string;
  domain: string;
  state: EnrollmentState;
  requiredRecords: RequiredTxtRecord[];
  lastObservation: null | {
    observedAt: string;
    result: "match" | "mismatch" | "inconclusive";
    answers: string[];
  };
};

type EnrollmentStore = {
  findByKey(tenantId: string, key: string): Promise<SenderEnrollment | null>;
  create(input: Omit<SenderEnrollment, "id"> & { idempotencyKey: string }):
    Promise<SenderEnrollment>;
};

async function enrollSenderDomain(
  input: { tenantId: string; domain: string; idempotencyKey: string },
  store: EnrollmentStore,
): Promise<SenderEnrollment> {
  const existing = await store.findByKey(input.tenantId, input.idempotencyKey);
  if (existing) return existing;

  const domain = input.domain.trim().toLowerCase();
  const policy: RequiredTxtRecord = {
    kind: "TXT",
    name: `_dmarc.${domain}`,
    value: "v=DMARC1; p=none",
    purpose: "dmarc-policy",
  };

  return store.create({
    tenantId: input.tenantId,
    domain,
    idempotencyKey: input.idempotencyKey,
    state: "pending",
    requiredRecords: [policy],
    lastObservation: null,
  });
}
Enter fullscreen mode Exit fullscreen mode

This sample deliberately uses p=none. RFC 7489 defines that requested policy as taking no specific action on mail that fails the DMARC mechanism. It is useful while collecting reports and understanding alignment, but it is not an enforcement policy. Do not quietly interpret it as protection against delivery or impersonation failures.

The same RFC says a DMARC record is a DNS TXT record at the _dmarc subdomain, begins with the v tag, and has the p tag immediately after it. It also defines DMARC evaluation in terms of an authenticated identifier aligned with the RFC5322.From domain. That is why a TXT string merely existing is weak evidence. The verifier has to parse the record, reject malformed or multiple-record conditions as specified, and evaluate whether the resulting policy is the one your gate expects.

The implementation above covers the control-plane shape, not the entire mail authentication system. An end-to-end production plan must also arrange the underlying authenticated identifiers used in DMARC evaluation. Their exact records and key material depend on the mail system. Keeping those implementation details behind the internal contract prevents the public support workflow from becoming coupled to one transport.

How does verification become deliverability evidence?

Run verification outside the request path. Read the exact owner name returned in requiredRecords, retain the raw TXT answers, parse the DMARC tags, and compare the parsed policy with the enrollment's expected policy. Then append an observation rather than overwriting history.

Evidence needs a clock. Store observedAt, the resolver vantage point, the raw answer, the parsed result, and a reason code. Those fields let an operator distinguish "the tenant published a different policy" from "the verifier could not obtain an answer." They also let a later audit reconstruct why sending was enabled without treating mutable DNS as if it were a permanent fact.

Raw answers matter.

A useful decision table stays small:

Observation Enrollment state Operator meaning
Expected record parses and matches ready The DNS gate may allow sending
Record is present but malformed or different blocked Show the exact expected and observed values
No conclusive answer was obtained pending Retry with bounded backoff; do not claim a mismatch

Record the transition as a structured event. For example, include tenantId, enrollmentId, domain, previous state, next state, result, reason, and observation time. Avoid logging message bodies, recipient addresses, or DNS credentials. The domain is already operational data; apply your normal retention and access controls to it.

Metrics should describe the funnel: enrollments created, observations by result, transitions to ready, and age of the oldest pending enrollment. Alert on a sustained age or an unusual mismatch rate, not on a single inconclusive lookup. One timeout is noise. A growing pending queue is a service condition.

DMARC aggregate reports add a second evidence stream after mail begins to flow. RFC 7489 defines the rua tag for aggregate feedback destinations and describes aggregate data about authentication results. Treat those reports as asynchronous operational feedback, not as proof that every individual message reached an inbox. Delivery, acceptance, and inbox placement are different questions. Keep the claim narrow.

What happens when tenants change DNS later?

Readiness cannot be a permanent badge. DNS remains under external control, and RFC 7489 describes receivers querying for the DMARC policy during message handling. Re-verify ready domains on a schedule and whenever an operator requests a check. If the observed policy no longer satisfies the gate, append the new evidence and move the enrollment according to an explicit safety rule.

That rule is a real product trade-off. Immediately pausing all support mail limits exposure to invalid policy but can interrupt an active support queue. Allowing a grace window protects continuity but accepts a period in which the evidence is stale. Pick the rule based on the harm model, publish it internally, and make it visible in alerts. Do not let an unreviewed retry loop make the decision by accident.

Also protect the ownership boundary. A tenant administrator should only enroll a domain for that tenant, and domain input needs normalization and validation before it becomes a DNS owner name. Authorization answers who may request the change. DNS observation answers what is published. They are separate checks.

Concurrency deserves one more guard: serialize state transitions per enrollment or use a compare-and-set version. Otherwise, an older inconclusive lookup can arrive after a newer match and push a ready domain back to pending. Store observations freely, but apply state changes only when their ordering and policy allow it.

Is one endpoint hiding too much?

It should hide orchestration, not evidence. A support application benefits from one command because it should not coordinate DNS polling, parser rules, and mail-system configuration. Operators still need a read view of the enrollment, required records, latest observation, and history. One write endpoint can coexist with transparent state.

Keep the endpoint narrow, too. It accepts tenant identity, domain, and idempotency key; it returns an enrollment and a publication plan. Background workers own verification. A separate status read can serve the UI without replaying setup. This division makes retries predictable and keeps a slow external lookup away from request latency.

The test strategy follows the same boundary. Unit-test domain normalization, DMARC parsing, and state transitions with fixed inputs. In integration tests, use a DNS zone you control and exercise match, mismatch, multiple-record, missing-answer, and timeout outcomes. Finally, test that a repeated idempotency key returns the same enrollment and that an old observation cannot reverse a newer decision. Five focused cases reveal more than a mock that always returns the requested TXT value.

The conclusion is practical: the endpoint creates intent; independent observations create trust. For tenant support mail, enable sending from a recorded policy decision, keep checking after activation, and make every state explainable from stored evidence.

References

Top comments (0)