Set up your customers' email sending domains
What a customer's email sending domain needs, which DNS records to write, and how to review, apply, and verify them from your app.
A sending domain proves to mailbox providers that your product is allowed to send as your customer. That proof is DNS records at the customer’s provider. This guide shows how to declare those records, have the customer approve them, and check that they resolve.
It covers DNS. Signing messages, choosing a sending service, and warming a domain are your product’s job.
The records a sending domain needs
| Record | Purpose | Type |
|---|---|---|
| SPF | Says which servers may send for the domain | TXT |
| DKIM | Lets receivers check a signature on each message | TXT or CNAME, depending on the sending service |
| Return-path or mail-from | Routes bounces, and aligns the envelope sender | MX and TXT, or a CNAME, depending on the service |
| Tracking domain | Serves branded links and open tracking | CNAME |
| DMARC | Tells receivers what to do when checks fail | TXT |
Sending services differ on the details. Postmark asks for a DKIM TXT record and a Return-Path CNAME (Postmark). Resend asks for MX and SPF TXT records on a send subdomain, plus DKIM (Resend). Declare whatever your service returns.
Put SPF where it can’t collide
A domain should have one SPF record. Two SPF records at the same name is a common failure.
Declare SPF with DnsRecord.spf. It is a standard TXT record that carries an SPF constraint, and
the constraint is part of the plan’s digest. Provider writes stay ordinary TXT. What happens at a
name depends on what the zone already holds:
- No SPF record is there, so the operation is
Create. Unrelated TXT, such as a verification token, doesn’t get in the way. - One matching SPF record is there, so the operation is
Noop. - A different SPF record, or more than one SPF record, is a
Conflictwith reasonspf-conflict. Two identical SPF records still conflict, and so does one matching record beside another SPF record. The plan can’t be approved as it stands, and nothing is written. - Two requested SPF values at the same name conflict inside one plan, so a plan never creates both.
/**
* `DnsRecord.spf` is a standard TXT record with an SPF constraint. It plans beside unrelated TXT
* such as verification tokens, and conflicts with a different SPF record, with more than one SPF
* record, or with another requested SPF value.
*/
export const spfRequirement = (name: string, value: string) =>
DnsRecord.spf({ name, value, purpose: "Authorize Acme to send" });DomainKit checks the SPF version token, v=spf1 followed by a space or the end of the value, and
rejects a DnsRecord.spf value without it. It doesn’t parse the rest of the policy, count DNS
lookups, merge two policies, or replace an existing one. Your product or the customer decides how
to combine them, and DomainKit reports the collision. Verification applies the same rules: a
duplicate or different SPF record reads as mismatch even when the matching record is present.
A generic DnsRecord.txt appends, even when its value is SPF, so an unmarked SPF value plans a
second SPF record beside an existing one. Use DnsRecord.spf for SPF.
exclusive is a broader tool. It makes any other record at that name and type a Conflict with
reason exclusive-name, not only SPF, and it does so even when the exact record is also present.
Use it at a name your service owns, such as a send subdomain. At a root domain that carries other
TXT records, such as verification tokens, it reports a conflict for those too.
/**
* A generic `DnsRecord.txt` appends, even when its value is SPF. `exclusive` makes any other TXT
* record at the name a Conflict, so use it only at a name the service owns.
*/
export const ownedTxtOrConflict = (name: string, value: string) =>
DnsRecord.txt({
name,
value,
policy: "exclusive",
purpose: "Authorize Acme to send",
});The simplest way to avoid a collision is to publish your SPF on a subdomain your service owns, as Resend does with its send subdomain, instead of the customer’s root domain. Then your record never competes with the one they already have. Check what your own service asks for before you copy that shape.
Declare the records
Build one requirement for each record your service returns. Use DnsRecord.spf for SPF, an
exclusive policy for MX when a second record at that name would break the first, and a CNAME for
DKIM and tracking.
The values here are placeholders.
/** What a sending service returns for one customer domain, as DomainKit requirements. */
export const requirements = (domain: string) => [
DnsRecord.spf({
name: `send.${domain}`,
value: "v=spf1 include:mail.acme.dev ~all",
purpose: "Authorize Acme to send for this domain",
}),
DnsRecord.mx({
name: `send.${domain}`,
exchange: "feedback.acme.dev",
priority: 10,
policy: "exclusive",
purpose: "Receive bounces",
}),
DnsRecord.cname({
name: `k1._domainkey.${domain}`,
target: "k1.dkim.acme.dev",
purpose: "Sign your mail",
}),
DnsRecord.cname({
name: `track.${domain}`,
target: "links.acme.dev",
purpose: "Serve branded links",
}),
];Connect, review, and apply
From here the flow is the one in the custom domain guide. The customer connects Cloudflare or Vercel once, sees each record marked Will add, Already set, or Conflict, and approves. DomainKit writes what they approved and keeps a receipt. Writes go one record at a time and are not atomic, so a failure part way gives a partial receipt, and planning again turns the landed records into no-ops.
Verify that the records resolve
Observation reads the provider and public DNS for each requirement and stores the evidence. Your readiness screen can show which records are still pending and when the next check runs.
/**
* One call reads the provider through the attachment's session and the public pool through
* `Resolver`, stores readiness per requirement, and says when to look again.
*/
export const check = Effect.map(Verify.observe({ domain }), (readiness) => ({
ready: readiness.overall === "ready",
nextCheckAt: readiness.nextCheckAt,
pending: readiness.requirements
.filter((requirement) => requirement.status !== "satisfied")
.map((requirement) => `${requirement.record._tag} ${requirement.record.name}`),
}));/**
* Every requirement keeps the evidence behind its status, one entry per source that answered.
* `values` is what that source returned for the record's name and type, empty when it returned
* nothing; `detail` is null when satisfied and otherwise says what went wrong.
*/
export const sources = Effect.map(Verify.observe({ domain }), (readiness) =>
readiness.requirements.flatMap((requirement) =>
requirement.evidence.map((evidence) => ({
record: requirement.record.name,
status: requirement.status,
source:
evidence._tag === "Provider"
? evidence.provider
: evidence._tag === "PublicDns"
? evidence.resolver
: evidence.source,
found: evidence._tag === "Host" ? [] : evidence.values,
detail: evidence.detail,
})),
),
);Verifying the DNS records is one step. Your sending service also has to confirm the domain on its side. Add that status to the same screen with host evidence.
/** Merge what only your app can see — an email identity, a certificate — without re-reading DNS. */
export const recordCertificate = Effect.gen(function* () {
const observedAt = yield* DateTime.now;
return yield* Verify.attachEvidence({
domain,
evidence: [
new Verify.HostEvidence({
source: "edge-certificate",
status: "pending",
label: "TLS certificate",
detail: "Issuance starts once the CNAME resolves",
observedAt,
}),
],
});
});DMARC
A DMARC record sets policy for the whole domain, and the customer may already have one. Check for it and recommend a record, and let the customer decide. Samva does this. It flags a missing DMARC record and lets verification pass without one.
Case study
Samva sets up its customers’ sending domains this way, with TXT, MX, and CNAME records on Cloudflare and Vercel. Read how Samva sets up customer domains.
FAQ
Does DomainKit sign my messages?
No. It writes the DNS records that let receivers check your signatures.
What if the customer already has an SPF record?
A different SPF record at the same name is a Conflict, and DomainKit doesn’t merge or replace
SPF records. See the section on putting SPF where it can’t collide.
What if the customer's DNS isn't on Cloudflare or Vercel?
Show them the records to add by hand. DomainKit can’t write there today.
Can I set up many domains at once?
Yes. Batches plan several domains together, with one digest for one consent.