01What each record actually proves
These are three different claims and they are constantly conflated, including by tools that should know better. Getting the distinction straight takes two minutes and saves an afternoon.
SPF and DKIM prove something about the delivery. DMARC is the only one that connects that proof to the name in the From line — which is the only part a recipient ever sees, the only one that lets you ask receivers to reject forgeries, and the only one that sends you reports.
| Record | What it proves | Checked against | Under forwarding |
|---|---|---|---|
| SPF | A list of servers allowed to send for this domain. | The envelope sender — the bounce address — not the From header a reader sees. | Breaks. The forwarder is not on your list. |
| DKIM | A cryptographic signature over the message itself, verified against a public key the receiver fetches from your DNS. | The body hash and a fixed list of headers. | Survives. The signature travels with the message. |
| DMARC | A policy saying what to do when neither of the above aligns with the From domain. | The address a human reads. It is the only one of the three that is. | Passes on whichever of the two still aligns. |
This is why one status is not enough. A verify returns three separate signals alongside the roll-up — dkim_ready, spf_ready and dmarc_policy — because collapsing them hides the two states that matter.
- A domain that delivers, and then fails alignment.
- A domain nobody is watching, including you.
02The records for your domain
Enter your domain and pick your transport. These rows come from the transport adapter’s own dnsRecords() function — the same code that produces your setup screen and then verifies what you published.
Generated by each adapter's own dnsRecords() in @mailysend/providers — the same function that produces the rows on your setup screen.
v=spf1 include:_spf.mx.cloudflare.net ~allAuthorises Cloudflare to send as this domain (SPF). Cloudflare adds this itself.
v=DKIM1DKIM key, minted and published by Cloudflare when you onboard the domain.
mx.cloudflare.netBounce collection, published by Cloudflare. Without it, DSNs are lost.
v=DMARC1; p=none; rua=mailto:[email protected]Turns on DMARC reporting. Start at p=none, tighten once reports are clean.
Rows marked published for you are written by the provider when you onboard the domain — there is nothing to copy, and verification confirms they exist rather than blocking on you. The DKIM value shown is a placeholder wherever the provider mints its own key under its own selector: printing a key that will not be the real one is worse than printing none.
| Transport | SPF | DKIM row | Return-path MX |
|---|---|---|---|
| Cloudflare Email Service | include:_spf.mx.cloudflare.net at the apex | cf-bounce._domainkey, holding Cloudflare’s own key | cf-bounce.yourdomain.com → mx.cloudflare.net |
| SES | include:amazonses.com at the apex, plus a second SPF record on the return-path subdomain | ms1._domainkey, holding the key MailySend generated and handed to SES as a BYODKIM signing attribute | On the return-path subdomain — a custom MAIL FROM domain, which is the thing that makes SPF align |
| Resend | On send.yourdomain.com | None issued here: the key is theirs and is fetched live from their API | On send.yourdomain.com |
| SMTP relay | v=spf1 a mx include:<your relay host> ~all | ms1._domainkey, holding our key — a raw relay signs nothing, so this row is not optional | The return path is used as the envelope sender |
All four also get a DMARC row, and it is the same row in every case: v=DMARC1; p=none; rua=mailto:[email protected].
A domain bound to a transport gets that transport’s rows and nothing else. A domain that names no transport shows the union across every provider the router could yield, because a workspace that fails over mid-incident needs the second transport’s records already in place. Where that union would produce two apex v=spf1 records, the includes are merged into one legal record — two SPF records at one name is a permanent error, not belt-and-braces.
03Alignment is the part people miss
A message can pass SPF, pass DKIM, and still fail DMARC. This surprises people every time, and it is not a bug in anything.
DMARC does not ask “did SPF pass?”. It asks “did SPF pass for a domain that matches the From header?” — and a check that passed for somebody else’s domain proved nothing about yours.
- The two domains have to share an organisational domain rather than match exactly
- That is the licence under which cf-bounce.yourdomain.com or send.yourdomain.com can carry the envelope sender and still align with a From at the apex
- Removes that licence: the domains must match exactly
- A deliberate late step rather than a default
Forwarding is where the two checks diverge. A mailing list or a university address that forwards to Gmail re-sends your message from its own server, so the connecting IP is the forwarder’s and SPF fails — correctly, by its own rules, on a message that is genuinely yours. The DKIM signature travels inside the message and keeps verifying, and DMARC needs only one of the two to pass and align. SPF is the check that fails exactly when a message reaches a mailbox that is judging you, which is the whole argument for publishing DKIM when SPF already passes.
What breaks a surviving signature. The signature covers the body hash and a fixed list of headers, so a list server that appends a footer or rewrites Subject with a [list-name] tag invalidates it. Nothing on your side prevents that — which is why DKIM failures from one mailing list are a fact about the list, and a rise across many receivers is a fact about you.
This is why the record sets above put the bounce path on a subdomain of your domain rather than the transport’s, and why the SMTP adapter uses the return path as the envelope sender rather than the From address.
04Selectors, key sizes and who mints the key
A selector is just a label that says which key to fetch — ms1 means the receiver looks up ms1._domainkey.yourdomain.com. Having a selector name at all is what lets you rotate a key without a gap.
The keypair is generated per domain when the domain is added, the private half has no read path anywhere in the API — and on two of the four transports it is not the key doing the signing at all.
RSA-2048, not Ed25519. Ed25519 signatures are smaller and better, and a meaningful share of receivers still do not verify them. A signature nobody checks is not an improvement, so the default is the one that works everywhere: 2048-bit RSASSA-PKCS1-v1_5 over SHA-256.
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=yourdomain.com; s=ms1; t=1757548800; bh=<base64 body hash>; h=from:to:subject:date:message-id:mime-version:content-type; b=<base64 signature>
| Tag | What the signer emits | Why |
|---|---|---|
| a= | rsa-sha256 | The algorithm every receiver verifies. |
| c= | relaxed/relaxed | Collapses whitespace runs and drops trailing whitespace before hashing, so a message survives an MTA reformatting a header. Simple canonicalisation would fail on transformations nobody can see. |
| h= | from, to, cc, subject, date, message-id, mime-version, content-type, content-transfer-encoding, reply-to, list-unsubscribe, list-unsubscribe-post | Filtered down to the ones actually present. Listing a header that is not there is how a signature ends up covering a header an attacker can then add. |
| b= | Folded at 72 columns | An unfolded 400-character header is the thing some MTAs truncate. |
The signature is prepended, so the Received lines each hop adds land above it and disturb nothing.
| Transport | Who actually signs | Under which selector |
|---|---|---|
| Cloudflare Email Service | Cloudflare, with its own key — the structured payload is what gets sent, not the MIME MailySend builds | cf-bounce._domainkey |
| Resend | Resend, with its own key | Its own |
| SES | SES, using the private key MailySend generated and handed over as a BYODKIM signing attribute | ms1 |
| SMTP relay | MailySend. A raw relay signs nothing, which is why this row is not optional there | ms1 |
On SES the published record, the selector in the signature and the key doing the signing describe one key rather than three unrelated ones.
05Checking your work
Two lookups and you are done. Do them from a resolver rather than from your DNS provider’s own interface, which will happily show you a record it has not published yet.
dig +short TXT yourdomain.com dig +short TXT ms1._domainkey.yourdomain.com dig +short TXT _dmarc.yourdomain.com
Verification does the same lookup you just did: one DNS-over-HTTPS query per row against cloudflare-dns.com/dns-query, reported as one of four words.
| Word | What it means | What it is worth doing about it |
|---|---|---|
| verified | It resolved and it agreed. | Nothing. |
| pending | Nothing is published at that name yet. | Publish it, or wait for it to propagate. |
| failed | A record exists and disagrees. | The actionable case — read the value that resolved. |
| error | The lookup itself did not complete. | No evidence about your zone at all. Without this fourth word a domain whose every row errored used to roll up to verified. |
| Row | Compared how | Because |
|---|---|---|
| apex SPF | On its includes | Merging our include into your existing record is the correct thing to do, and an exact match would punish it. |
| DKIM / DMARC | On its prefix | The value is one only the provider knows, so v=DKIM1 passes against a full key and tightening p=none to p=quarantine does not turn a verified row red. |
| everything else | Exactly | After whitespace, a trailing dot and case are normalised away. |
The domain rolls up conservatively. A domain is verified only when every row is; any failed row makes it failed; everything else is pending. Note what is not in that rule: rows the provider publishes for itself are not excluded, so a Cloudflare domain you have not yet onboarded sits at pending with nothing for you to copy. The response’s checked block — total, resolved, errored — is how you tell “still propagating” from “the resolver would not answer”.
| The mistake | What it actually does | The fix |
|---|---|---|
| Two SPF records | A domain may publish exactly one. Two is a permanent error and receivers treat it as no SPF at all, so adding a second record for a new sender silently disables the first. | Merge the includes into one record. The setup screen does this for you when two transports both want the apex, keeping the first record’s ~all or -all qualifier; what it cannot merge is a record some other tool added to your zone last year. |
| More than ten lookups | SPF evaluation is capped at ten mechanisms that require a lookup — include, a, mx, ptr, exists, redirect — counted recursively, so one include that itself includes three more spends four of your ten. Exceeding it is a permerror, which most receivers treat as no SPF. | Count them by hand once the apex record passes three or four includes. Nothing in the verifier counts them for you: it checks that your includes are present, not that the tree beneath them is small enough. |
| A key your panel split | A TXT record is a sequence of strings of at most 255 bytes each, and a 2048-bit key does not fit in one. Well-behaved panels split the value into quoted strings a resolver concatenates back, and verification joins the chunks a DoH answer arrives in before comparing. Badly behaved panels truncate at 255 characters or insert whitespace at the split. | Compare the p= value from dig, byte for byte, against the key you published. Because the row is prefix-matched on v=DKIM1, a truncated key can still verify here while the signature fails at every receiver. |
| p=quarantine with no rua | An instruction to receivers with no feedback to you: you have asked mailbox providers to start quarantining mail that fails alignment, and arranged to hear nothing about which mail that is — including the invoicing system, the helpdesk and the CRM you forgot about. | Publish p=none with a working rua first, read the reports until you recognise every source in them, and tighten after. That is why the default row for every transport is v=DMARC1; p=none; rua=mailto:[email protected], and the mailbox it names has to be one that can actually receive mail. |
| A registrar that appends | Many panels take the name you type and append the zone. Entering _dmarc.yourdomain.com then produces _dmarc.yourdomain.com.yourdomain.com, which resolves to nothing and looks correct in the interface. | If dig disagrees with your DNS panel, believe dig. |
The ten-lookup one fails in the worst possible way: it works until a vendor adds an include inside their own record, and your SPF stops passing with nothing having changed on your side.
What just happened
Three records are published and you can say what each one proves: SPF authorises a server, DKIM signs the message, DMARC checks that one of those two passed for the domain the reader sees. That last clause is alignment, and it is the part that turns two passing checks into a failing DMARC verdict.