A visitor fills in your contact form. The page says thank you. Nothing arrives. You send a test to your own Gmail address and it lands in spam, or it lands nowhere at all, with no bounce, no error, and nothing useful in any log on the server.
Three DNS records decide that, and they are still evaluated in the order they were invented. SPF (2006, now RFC 7208) lists the servers allowed to send for your domain. DKIM (2007, now RFC 6376) signs the message so a receiver can prove it was not altered on the way. DMARC (RFC 7489, 2015) ties both results to the address a human reads, and says what to do when they disagree.
All three can be published, all three can look correct when you query them, and delivery can still fail. Usually because of alignment: a passing SPF check only counts if the domain SPF checked is related to the domain in the From header your reader sees. Those are two different addresses, and nothing in a default WordPress install makes them the same.
Three records, three different things being checked
Be precise about this early and it saves hours later, because all three are plain TXT records that check nothing alike. SPF is evaluated against the connecting IP and the domain in the SMTP MAIL FROM command, an address that lands in Return-Path and that no mail client shows. DKIM is evaluated against a signature header, using a public key at a selector you choose. DMARC is evaluated last, against the other two results, and it is the only one that reads the visible From.
The builder below runs all three in both directions at once. Fill in the form and it writes the record; paste a record from a DNS answer, quotes and all, and it parses it token by token, explains every term in a sentence and grades what it found.
SPF, DKIM and DMARC builder
Three DNS records decide whether your mail arrives: SPF names the machines allowed to send, DKIM publishes the key that verifies the signature, DMARC says what a receiver should do when neither lines up. Fill a form and the record is written below it, or paste a record you already have and it is read back token by token and graded. Everything happens in this browser tab. The tool never queries DNS, so it cannot follow an include or fetch a key, and it says where that limit bites instead of guessing.
One TXT record at the domain itself. A receiver reads the mechanisms left to right and stops at the first one that matches the connecting address. Everything in it costs DNS lookups except ip4 and ip6, and ten is the hard ceiling.
Type over it or paste one from a DNS answer, quotes and all, and the reading below follows what is in the box. Publish exactly one SPF record per domain: two of them is a permerror, and a permerror counts as no SPF at all.
| Term | What a receiver does with it | Lookups |
|---|
Over 255 characters, so it does not fit in one character-string. Publish it as one TXT record holding these strings, which most DNS panels do for you when you paste the long line:
One TXT record per selector, published at selector._domainkey.domain. The signature in the message header names the selector, the receiver fetches this record, and the public key in it either verifies the signature or does not. The private half never leaves your mail server, and nothing you paste here leaves this tab.
Any label you like. Use a new one every time you rotate the key, so the old signatures keep verifying while the new ones start.
The domain the signature claims, the d= tag in the header.
The value only. Some panels want the name in the left field and this in the right, some want the whole line, and a few insist on the quoted strings shown below.
| Tag | What a receiver does with it | State |
|---|
One TXT record, holding these strings one after the other. Most DNS panels do the splitting for you when you paste the long line, and the ones that do not are why this is here:
One TXT record at _dmarc.domain. It only applies to the domain a reader sees in the From line, and it only passes when SPF or DKIM passes and that passing domain lines up with the From domain. Alignment is the whole point: without it, anyone with an SPF record of their own could send as you.
Used for the record name, and to see whether the report addresses sit on a domain that has to authorise them first.
A tag left at its default is not written out. A receiver assumes the default anyway, and a short record is easier to read six months later. The defaults in force are listed below.
| Tag | What a receiver does with it | State |
|---|
Over 255 characters, so it needs to go out as more than one character-string in the one TXT record:
What this tool cannot do. It never asks a DNS server anything, so an include is counted as one lookup and nothing more, a key is never fetched from the selector you name, and a domain that does not exist looks exactly like one that does. Everything here is the record read as text, which is also how a receiver reads it once it has the answer in hand.
Everything happens in your browser. Nothing you type is uploaded and there is no network request of any kind, which is also the honest limitation: it grades the text of a record, not the reality behind it. It cannot query DNS, so it cannot follow an include or fetch the key at the selector you name.
SPF, and the address nobody reads
An SPF record is one TXT record at the domain itself. It starts with v=spf1 and lists terms read left to right until one matches the connecting IP. Each term has a mechanism (a, mx, ip4, ip6, include, exists, ptr) and an optional qualifier deciding the verdict when it matches: + pass, - fail, ~ softfail, ? neutral. Leave the qualifier off and it is a plus.
v=spf1 ip4:198.51.100.14 include:_spf.google.com include:servers.mcsv.net ~all
Mail from 198.51.100.14 passes, anything Google’s or Mailchimp’s record vouches for passes, everything else is a softfail. That last term, all, decides every sender you did not list. -all says the list is complete and anything else is forged. ~all says probably not us, accept but note it. ?all says nothing at all, and +all authorises the entire internet to send as you, which is worse than publishing no record.
One detail matters more than the endless -all versus ~all argument: DMARC only cares whether SPF returned pass. Softfail and fail are both not-a-pass, so they behave identically for alignment. That is why moving from ~all to -all so often changes nothing you can see.
The remaining faults are structural. Two SPF records on one domain is a permanent error, not a merge. A second all is dead code, and any term after all is never reached. A redirect= modifier is ignored entirely when an all is present, so the two together are almost always a mistake. A missing v=spf1 means the record is not an SPF record at all.
DKIM, the signature that travels with the message
DKIM adds a header to every outgoing message: the signing domain in d=, the selector in s=, the headers covered in h=, the signature in b=. A receiver queries selector._domainkey.yourdomain.com, pulls the public key out of the p= tag and verifies. Change the body or a covered header in transit and the signature breaks.
selector1._domainkey.example.com. IN TXT (
"v=DKIM1; k=rsa; h=sha256; "
"p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA..." )
The p= value is not raw key material. It is base64 of a DER SubjectPublicKeyInfo: a wrapper naming the algorithm, then a BIT STRING holding a SEQUENCE of two INTEGERs, the modulus and the exponent. That is why the builder walks the DER down to the modulus instead of measuring the base64, which overshoots by the size of the wrapper, roughly thirty bytes. A 2048 bit RSA key serialises to 294 bytes of DER and exactly 392 characters of base64; a 1024 bit key to 162 bytes and 216 characters. An Ed25519 key under RFC 8463 is different again: 32 raw bytes, 44 base64 characters and k=ed25519. To see those bytes yourself, the base64 decoder in the hashes and encoding tool takes a p= value straight out of a record.
RFC 8301 set the floor at 1024 bits, the target at 2048, and forbade rsa-sha1 outright, so h=sha1 is a finding worth acting on. Two flags are easy to leave on by accident. t=y is test mode: receivers treat the signature as if DKIM were not deployed, so a broken signature costs nothing and so does a working one. t=s means d= must match exactly, no subdomains. And an empty p= is not a typo, it is a revocation.
One quirk trips people up in DNS panels. A single character-string holds at most 255 octets and a 2048 bit key cannot fit, so the record is published as several quoted strings inside one TXT record, joined end to end by the resolver. Most panels split it for you, a few reject the value. The builder writes the split out and treats it as information rather than a fault.
DMARC, the record that reads the From header
DMARC lives at _dmarc.yourdomain.com, must start with v=DMARC1 and must carry a p= policy. Everything else has a default, and the defaults are in force whether or not you write them:
spinheritsp, so a subdomain policy exists even when you never wrote one.adkimandaspfdefault tor, relaxed alignment.pctdefaults to 100 and applies only to quarantine and reject.ridefaults to 86400 seconds, one aggregate report per receiver per day.fodefaults to 0 and does nothing without arufaddress to send to.
rua is the address for aggregate reports: daily XML naming every IP that sent mail claiming to be you, and whether each passed SPF, passed DKIM and aligned. It is the most valuable tag in the record. ruf asks for per message failure reports, which most large receivers stopped sending years ago on privacy grounds. Both need the mailto: scheme spelled out. And the record that does nothing and tells nobody is v=DMARC1; p=none with no rua at all. It is valid, it satisfies a checkbox on a security questionnaire, and it produces neither protection nor information.
One reporting rule surprises everybody. If rua points at a mailbox on a different domain, that domain must authorise it by publishing yourdomain.com._report._dmarc.reportingdomain.com containing v=DMARC1. Without it a conforming receiver will not send, and you wait for data that was never coming. The builder flags any off-domain reporting address, which is exactly the case where a hosted DMARC service is involved.
Alignment, the part everybody misses
DMARC passes if at least one of SPF and DKIM passes and is aligned with the From header. Not both. One is enough, which is the mercy in the design, but the alignment half is not optional.
SPF alignment compares the envelope sender domain with the From domain. DKIM alignment compares d= with the From domain. Relaxed alignment, the default, accepts a match at the organisational domain, so mail.example.com aligns with example.com. Strict alignment demands identical labels.
Now the WordPress case. When PHP hands a message to the local sendmail binary, the envelope sender becomes whatever the local mail transport decides, typically the web server user at the server’s own hostname: www-data@srv12.hostingcompany.net. SPF for that hostname may well pass, because the server really is allowed to send its own mail. But the From header says wordpress@yourdomain.com. Different organisational domain, no alignment, no DKIM signature to fall back on. SPF passed, DMARC failed, and under p=reject your own contact form mail is unroutable while every SPF checker you try reports green.
The second classic failure is a contact form that sends as the visitor so that hitting reply works. The message claims a From of someone@yahoo.com, sent from your web server, which is not in Yahoo’s SPF record and cannot produce Yahoo’s DKIM signature, against a domain publishing p=reject. That mail is refused, not filtered. Put your own address in From and the visitor in Reply-To.
The ten lookup limit
RFC 7208 section 4.6.4 allows at most ten DNS-querying terms while evaluating a single SPF record. Exceed it and the check must return permerror, which is not a pass, which under DMARC means SPF contributed nothing at all.
The trap is that the budget is spent recursively. An include costs one for itself plus everything inside the record it points at. include:_spf.google.com has long expanded to three further _netblocks includes, so it takes four of your ten, not one. Add a newsletter platform two deep, then a helpdesk, then an invoicing tool, and you reach the ceiling with no warning. The record that worked for six years breaks the day somebody appends one more include, and every sender in it stops counting at once.
Two smaller caps sit alongside. An mx mechanism may not trigger more than ten address lookups of its own, and the evaluation is allowed at most two void lookups, queries returning no answer, which is how a record rots as old providers get decommissioned. ptr is deprecated outright and should not appear in a record written today.
Because the builder never touches the network, it counts each include, exists and redirect as exactly one and names every one it could not follow. The finding says so plainly: one is the floor, not the price. Fetch that record, paste it into the box, count it too. Flattening includes into a list of ip4 ranges does get you under ten, and it breaks silently the next time a provider changes addresses.
WordPress mail, and why an SMTP plugin is the fix
Look at what core actually does. wp_mail() builds a PHPMailer object and, unless a plugin intervenes on phpmailer_init, calls $phpmailer->isMail(). That is PHP’s mail() function, which hands the message to a sendmail binary on the box. Nothing in that path signs anything, nothing sets a sensible envelope sender, and no mail service is involved that could vouch for you.
The default sender is built in wp-includes/pluggable.php: the name is WordPress and the address is wordpress@ plus the host of network_home_url() with a leading www. stripped. Core’s own comment admits some hosts block outgoing mail from that address because the mailbox does not exist, and bounces to a mailbox that is not there go nowhere.
The fix for almost every deliverability problem on a WordPress site is to stop sending from the web server. An SMTP plugin makes wp_mail() authenticate to a real mail service instead. That service sends from IPs already in its own SPF record, signs with a DKIM key for your domain once you publish the selector it gives you, and lets you set a From address that is a real mailbox. Envelope and header then share a domain, and alignment stops being something you think about.
Two practical notes. An SMTP password stored in plugin options lives in the database, and in every backup; most SMTP plugins accept PHP constants instead, and wp-config.php is the right place for credentials like that. And wp_mail() is synchronous, so the handshake happens inside the request that triggered it: a form submit can wait a second or more on a remote mail server, worth remembering when you are working out why a site feels slow. Plugins that queue mail to avoid that lean on WP-Cron, which only fires when somebody visits.
A rollout that does not lose mail
Publish p=none with a rua address first and change nothing else. None means no receiver alters its handling, so nothing can break, while every participating receiver starts sending daily XML naming the IPs that send as your domain. That list is always longer than expected: the CRM, the invoicing tool, an old plugin on a staging site, a form on a subdomain you forgot existed.
_dmarc.example.com. IN TXT "v=DMARC1; p=none; rua=mailto:dmarc@example.com; sp=none; adkim=r; aspf=r"
Stay there until you can name every source in the reports and every legitimate one passes aligned. Then move to p=quarantine, optionally with pct below 100 to sample, though treat pct as a hint: receivers apply it inconsistently, it affects only quarantine and reject, and the current DMARC revision work drops it. Then reject. Set sp deliberately at each step, because the subdomains you never send from are precisely what gets spoofed.
The shape should feel familiar if you have ever switched on a strict browser policy: publish in monitoring mode, read what breaks, then enforce. It is what Content Security Policy report-only mode exists for, and it works for the same reason, because the reports name the legitimate traffic you forgot about before you start blocking it.
What the builder deliberately will not tell you
No network means no DNS, and that shapes the honest edges. It cannot see a second SPF record on the same domain, an instant permanent error however well each is written. It cannot fetch the key at the selector you name, so it grades the key you paste, not the key that is published. It cannot count void lookups, and the domain fields serve the label and the external reporting check, nothing more.
Other omissions are on purpose. There is no key generation, because the private half of a DKIM keypair has no business in a browser tab. There is no BIMI, no MTA-STS and no ARC. The “Read it into the form” direction drops terms the form cannot represent, such as redirect=, exp= and unknown tags, naming each one in the status line rather than handing back a record that quietly lost something.
Where this leaves you
Mail authentication feels arbitrary because it looks like three independent checkboxes and behaves like one system with a hidden joining condition. SPF and DKIM are evidence. DMARC is the only part that asks the question anybody cares about: whether the name on the From line has anything to do with whoever sent the message. Publish SPF and DKIM without DMARC and you have collected evidence nobody will weigh. Publish DMARC without understanding alignment and you will enforce a policy against your own contact form.
For a WordPress site the order is short. Move mail off the web server onto a service that will sign for your domain. Set From to a mailbox that exists and keep the visitor in Reply-To. Publish SPF with a single deliberate all and count the lookups before adding an eleventh source. Publish DKIM with a 2048 bit key and no lingering t=y. Then publish p=none with a reporting address and read what comes back for a month.
The reports are the part that keeps paying off, because a domain’s sending profile drifts. A tool gets added, another gets cancelled, a plugin starts sending from somewhere unexpected. A record graded green today and never looked at again is how a domain ends up at eleven lookups, one revoked selector and a policy that rejects the only mail you wanted.