DNS for Email: Keeping Bookstore's Mail Out of Spam with MX, SPF, DKIM and DMARC
Passing SPF or DKIM is not enough to keep mail out of spam; DMARC requires the passing domain to align with the From: domain the user sees, and Gmail, Yahoo and Outlook require this of bulk senders. In a lab we set up Bookstore's MX, SPF, DKIM and DMARC records, sign and verify an order mail with real libraries, and see that the 10-lookup limit breaks only for some IPs and why a provider's default DKIM fails DMARC.
Version note: The examples in this post were run with BIND 9.20 and dig 9.20, and the SPF/DKIM/DMARC evaluations with pyspf 2.0.14, dkimpy 1.1.8 and authheaders 0.16.3 on Python 3.13, in a lab on Docker 29; the mailbox providers' rules were read from their official pages on 14 November 2026.
Guess First
Bookstore, the online bookstore used as the shared example across this site's series, sends order confirmations and password reset mails through a transactional e-mail provider, a service the application calls over an API to send mail. The team's own mailboxes are on a separate corporate mail service. The provider's setup wizard said "add include:_spf.txprovider.example to your SPF record", and Emre did. Sending works, but Gül's customer support inbox is getting complaints that "my order confirmation went to spam".
Guess: if the SPF record is right and the provider signs mails with DKIM, why might the receiving server not trust this mail? Hint: the "sender" Gmail shows is not the same thing as the domain SPF and DKIM check. In this post we set up MX, SPF, DKIM and DMARC in the lab and actually sign and verify a mail; the answer comes out of the concept of alignment.
Scenario and Target Setup
Bookstore has two separate mail streams, and separating them is the main decision in this post:
| Stream | From address | Infrastructure | Domain |
|---|---|---|---|
| Corporate mail (team, support) | ayse@bookstore.example | Corporate mail service | bookstore.example |
| Transactional mail (orders, passwords) | orders@bookstore.example | Transactional provider | Envelope and DKIM: mail.bookstore.example |
The visible address of transactional mail is still on bookstore.example, but its technical identity (the bounce address and the DKIM signature) lives on the mail.bookstore.example subdomain. The reason is reputation: if something goes wrong in the transactional stream (thousands of mails because of a bug, a high complaint rate), the impact on the corporate mail's reputation is smaller. The SPF and DKIM records also live in separate places, so a change to one doesn't break the other. The mail part of the lab zone:
; mail
@ IN MX 10 mx1.corpmail.example.
@ IN MX 20 mx2.corpmail.example.
@ IN TXT "v=spf1 include:_spf.corpmail.example -all"
_dmarc IN TXT "v=DMARC1; p=none; rua=mailto:dmarc-reports@bookstore.example"
mail IN MX 10 feedback.txprovider.example.
mail IN TXT "v=spf1 include:_spf.txprovider.example -all"
s1._domainkey.mail IN TXT ( "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2rU8UK0/E30OMTz0FfG6xSj4OkB4X3BV5yoEr/czlZ1GijiMrbV9pJ8hy+9Oq+tMm8gBthy2cwZs605sA+kj+X2DhWLWgF7e0D+LxrLSUEL3JtfnBsGyR8JU6vKZ9ByG48EowUIX1V4OmrG7GaWt6Zei0oPA"
"K4ryXlatZ1uPX5oSxA/xRfkIbu2VvJqk8e3Z0x1yzafQkY3804yeJAwkHwI1LZYH/AkB2IYkGYLf8HXvDNbz4UsEvTunxSnNVeMtsuMO6QztcjKihB+VWC8MnzSDcfTvXe3Z5B3JcaP0366G4V3+hTrgkLBuWil9zaUlvFmsc2tC51Yfmg9XUK0AVQIDAQAB" )
In the lab, two zones stand in for the corporate mail service (corpmail.example) and the transactional provider (txprovider.example); both are on the same DNS container. I ran the evaluations in a Python container on the same Docker network, with real SPF/DKIM/DMARC libraries. The container's resolver points at the lab's DNS server.
MX: Where Does Mail Arrive?
MX only concerns incoming mail. It plays no role in sending, so "we changed MX and sending broke" doesn't happen, but "we changed MX and incoming mail disappeared" happens a lot. The number is the preference: try 10 first, then 20 if it is unreachable. An MX record must point to a hostname; pointing it at an IP address or a CNAME violates the RFCs and some servers reject it.
The MX on the mail subdomain is interesting: nobody is going to write to @mail.bookstore.example. But the transactional provider sets the envelope sender (SMTP's MAIL FROM, the address bounces return to) to something like bounces@mail.bookstore.example, and to process returned mail it wants this subdomain's MX to point at its own server. The "custom return-path" or "custom MAIL FROM" step in the provider's setup screen is exactly this.
SPF: May This IP Send on My Behalf?
SPF looks at the domain of the envelope sender, not at the From: address shown inside the mail. The receiving server compares the IP the connection came from with this list:
import dkim, spf
from authheaders import authenticate_message
def message(sender: str, body: str) -> bytes:
return (f"From: Bookstore <{sender}>\r\n"
"To: Ada <ada@example.com>\r\n"
"Subject: Order BK-1042 confirmed\r\n"
"Date: Sat, 14 Nov 2026 10:15:00 +0000\r\n"
"Message-ID: <bk-1042@mail.bookstore.example>\r\n"
"\r\n" + body).encode()
def sign(msg: bytes, selector: str, domain: str, key_file: str) -> bytes:
key = open(key_file, "rb").read()
header = dkim.sign(msg, selector.encode(), domain.encode(), key,
include_headers=[b"From", b"To", b"Subject", b"Date", b"Message-ID"])
return header + msg
print("SPF")
for ip, sender, helo in [("198.51.100.25", "bounces@mail.bookstore.example", "mta1.txprovider.example"),
("203.0.113.10", "bounces@mail.bookstore.example", "web1.bookstore.example"),
("203.0.113.130", "ayse@bookstore.example", "mx1.corpmail.example")]:
result, explanation = spf.check2(i=ip, s=sender, h=helo)
print(f" {ip:<15} {sender:<32} -> {result}")
SPF
198.51.100.25 bounces@mail.bookstore.example -> pass
203.0.113.10 bounces@mail.bookstore.example -> fail
203.0.113.130 ayse@bookstore.example -> pass
The second line is a real story: if the server running Bookstore.Api (203.0.113.10) one day starts sending mail directly over SMTP instead of through the provider as a "quick fix", SPF returns fail. The -all at the end of the record means "every IP not on the list should be rejected"; ~all is softfail, "treat as suspicious but don't reject". On a domain with DMARC the practical difference is small, because DMARC makes the final decision; for receivers without DMARC, -all is the clearer signal. All of Bookstore's sending sources are known, so we use -all.
Two SPF rules get broken once in almost every team. The first: a name can have only one SPF record. If someone says "let's add another TXT" for a new service, the result is:
$ dig @192.0.2.20 two-spf.bookstore.example TXT +short
"v=spf1 include:_spf.corpmail.example -all"
"v=spf1 include:_spf.txprovider.example -all"
$ python -c "import spf; print(spf.check2(i='198.51.100.25', s='orders@two-spf.bookstore.example', h='mta1.txprovider.example'))"
('permerror', 'SPF Permanent Error: Two or more type TXT spf records found.')
You have to merge them: v=spf1 include:_spf.corpmail.example include:_spf.txprovider.example -all. The second is sneakier: an SPF evaluation may make at most 10 DNS queries (RFC 7208). include, a, mx, exists and redirect each count as one; ip4/ip6 don't. The problem is that every include can contain more includes, and those are under someone else's control. I tried a record that had picked up a CRM, a help desk tool and a newsletter service over a year, on a separate name, and wrote a script that counts the include tree:
#!/bin/bash
# Usage: spf-lookups.sh <domain> [resolver]
# Prints the SPF include tree and counts DNS-querying terms (limit: 10).
resolver=${2:+@$2}
total=0
walk() {
local domain=$1 indent=$2 record term
record=$(dig $resolver +short "$domain" TXT | tr -d '"' | grep '^v=spf1')
if [[ $(grep -c . <<<"$record") -gt 1 ]]; then
echo "${indent}$domain: MORE THAN ONE SPF RECORD (permerror)"; return
fi
echo "${indent}$domain"
for term in $record; do
case "$term" in
include:*|redirect=*)
total=$((total + 1)); walk "${term#*[:=]}" "$indent " ;;
a|a:*|mx|mx:*|ptr|ptr:*|exists:*)
total=$((total + 1)); echo "$indent $term" ;;
esac
done
}
walk "$1" ""
echo "DNS lookups: $total/10"
(( total <= 10 ))
$ ./spf-lookups.sh google.com 1.1.1.1
google.com
_spf.google.com
DNS lookups: 1/10
$ ./spf-lookups.sh spf-2027.bookstore.example 192.0.2.20
spf-2027.bookstore.example
_spf.corpmail.example
_spf.txprovider.example
_spf.crm.saas.example
_spf1.crm.saas.example
_spf2.crm.saas.example
_spf.helpdesk.saas.example
_netblocks1.helpdesk.saas.example
_netblocks2.helpdesk.saas.example
_netblocks3.helpdesk.saas.example
_spf.newsletter.saas.example
a
mx
_spf1.newsletter.saas.example
DNS lookups: 13/10
The record contains only five includes but makes 13 queries in total. The truly surprising part is on the receiving side:
$ python -c "
import spf
for ip in ['198.51.100.25', '198.51.100.130', '198.51.100.241', '192.0.2.99']:
print(ip, spf.check2(i=ip, s='orders@spf-2027.bookstore.example', h='mta1.txprovider.example'))"
198.51.100.25 ('pass', 'sender SPF authorized')
198.51.100.130 ('pass', 'sender SPF authorized')
198.51.100.241 ('permerror', 'SPF Permanent Error: Too many DNS lookups')
192.0.2.99 ('permerror', 'SPF Permanent Error: Too many DNS lookups')
SPF is evaluated left to right and stops at the first match. The transactional provider's IP matches in the second include, so the limit is never reached and the result is pass. The newsletter service's IP is at the end of the tree; the query count passes 10 before evaluation gets there, and the result is permerror. So SPF breaks "for some mails" while everything looks green in tests. The fix is to drop services you no longer use, split sending streams across subdomains (each subdomain has its own budget of 10 queries) and, where the provider allows it, write IP ranges instead of an include. The last option is risky: when the provider changes IPs, your record goes stale.
DKIM: Signing Mail on Behalf of the Domain
DKIM proves that the mail was not changed in transit and that the signing domain sent it. The public key lives in a TXT at <selector>._domainkey.<domain>. The selector lets several keys live side by side on the same domain. In the lab I generated a 2048-bit RSA key with openssl genrsa -out s1.private.pem 2048 (Google lists 1024 bits as the minimum and 2048 as the recommended size), put the public key at s1._domainkey.mail.bookstore.example and signed an order confirmation. TXT values longer than 255 characters are split into quoted chunks in the zone file as above; DNS concatenates them and reads a single value. The rest of the script:
body = "Your order BK-1042 (Dune, Emma) is confirmed. Total: 640.00 TRY\r\n"
signed = sign(message("orders@bookstore.example", body), "s1", "mail.bookstore.example", "/work/s1.private.pem")
print(b"\r\n".join(signed.split(b"\r\n")[:3]).decode())
print("DKIM verify (original):", dkim.verify(signed))
tampered = signed.replace(b"640.00", b"6.40")
print("DKIM verify (body changed):", dkim.verify(tampered))
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple;
d=mail.bookstore.example; i=@mail.bookstore.example; q=dns/txt; s=s1;
t=1794621927; h=from : to : subject : date : message-id;
DKIM verify (original): True
DKIM verify (body changed): False
d= is the signing domain, s= the selector, h= the headers included in the signature. The verifier read s1._domainkey.mail.bookstore.example from DNS and checked the signature. Changing the amount in the body from 640.00 to 6.40 broke it. In real life the thing that breaks DKIM most often is not an attacker but systems in the middle: a gateway that appends "this mail was scanned for viruses" or a rule that writes [EXTERNAL] into the subject line.
Selectors exist for key rotation. Most transactional providers ask you to point s1._domainkey at their servers with a CNAME, so they rotate the key. If you manage the key yourself, the order is: add the new selector (s2) to DNS, wait for the TTL, switch signing to s2, keep s1 for a few more days so mail in transit can still be verified, then empty s1's p= value. Per RFC 6376 an empty p= means "this key has been revoked".
DMARC and Alignment: the Answer to Guess First
SPF and DKIM share a gap: neither looks at the From: address the user sees. SPF validates the envelope sender's domain, DKIM the domain in d=. A fraudster can send a mail from their own domain that passes SPF and DKIM and put orders@bookstore.example in From:. DMARC closes this gap: at least one of SPF or DKIM must pass, and the domain it passed for must be aligned with the From: domain. With relaxed alignment the same organisational domain is enough (mail.bookstore.example aligns with bookstore.example); with strict alignment (aspf=s, adkim=s) they must be identical.
I set up the Guess First situation in the lab. The first mail goes out with Bookstore's own DKIM key and its own bounce address; the second with the provider's defaults: the provider signs with its own domain and the bounce address is on its domain as well:
def results(msg: bytes, mail_from: str) -> str:
return authenticate_message(msg, "mx.example.com", ip="198.51.100.25", mail_from=mail_from,
helo="mta1.txprovider.example", spf=True, dkim=True, dmarc=True)
print("Own domain, aligned:")
print(" " + results(signed, "bounces@mail.bookstore.example"))
provider_signed = sign(message("orders@bookstore.example", body), "tx1", "txprovider.example", "/work/tx1.private.pem")
print("Provider defaults, not aligned:")
print(" " + results(provider_signed, "bounces@txprovider.example"))
Own domain, aligned:
Authentication-Results: mx.example.com; spf=pass reason="sender SPF authorized" smtp.helo=mta1.txprovider.example smtp.mailfrom=mail.bookstore.example; dkim=pass header.d=mail.bookstore.example header.i=@mail.bookstore.example; dmarc=pass (Used From Domain Record) header.from=bookstore.example policy.dmarc=none
Provider defaults, not aligned:
Authentication-Results: mx.example.com; spf=none smtp.helo=mta1.txprovider.example smtp.mailfrom=bounces@txprovider.example; dkim=pass header.d=txprovider.example header.i=@txprovider.example; dmarc=fail (Used From Domain Record) header.from=bookstore.example policy.dmarc=none
The answer is on the second line. DKIM is pass, but the signer is txprovider.example while the user sees bookstore.example; they are not aligned and DMARC is fail. The include Emre added to SPF does nothing in this scenario, because the envelope sender is also on the provider's domain and Bookstore's SPF record is never consulted. The policy is p=none, so the mail was not rejected, but Gmail and Outlook expect bulk senders to pass DMARC; unaligned mail is a prime candidate for the spam folder. The fix is in the provider's panel: "custom DKIM" (or "domain authentication") for a d=mail.bookstore.example signature and "custom return-path" for the bounce address. The first line is the result of those two settings.
A note about the lab: the alignment calculation uses the Public Suffix List, the list of registration suffixes such as co.uk and com.tr, to find the "organisational domain". .example, reserved for documentation, is not on that list, so on my first attempt the library treated bookstore.example and txprovider.example as the same organisation and passed the second mail too. Adding an example line to the list fixed the result. RFC 9989, published in May 2026 to replace RFC 7489, defines finding the organisational domain by walking up the DNS tree instead of using that list, and this problem is exactly why.
Tightening DMARC Gradually and Reading Reports
The most important field in a DMARC record is p: none (report only), quarantine (send to spam) or reject. rua is the address receivers send daily aggregate reports to. These reports are XML, and each row says "from this IP, this many mails, SPF this, DKIM that, alignment this". Every system you forgot that sends mail as your domain (an old newsletter tool, accounting's invoicing system, an office scanner's scan-to-mail) shows up in these reports. Raw XML is hard to read; open source parsers or commercial DMARC reporting services turn it into tables. If the report address is on another domain (a reporting service, for instance), that domain must declare that it accepts the reports with a TXT named bookstore.example._report._dmarc.<service-domain>; otherwise receivers won't send them.
A timeline for Bookstore:
- Week 1: start with
p=none; rua=.... No mail is affected; only reports arrive. - Weeks 2-4: for each source in the reports, either fix DKIM/SPF or shut the source down. The goal is all legitimate traffic passing aligned.
- Week 5:
p=quarantine. RFC 9989 removed the oldpctfield and introducedt=y(test mode). Witht=ythe receiver applies the policy one level lighter:quarantineeffectively behaves likenone,rejectlikequarantine. It can be used as an intermediate step. The record still starts withv=DMARC1and old records remain valid. - Once a few weeks pass without problems:
p=reject. Subdomains can get a separate policy withsp=; ifmail.bookstore.exampleis already aligned it can take the same policy.
The Big Mailbox Providers' Bulk Sender Rules
None of this is "nice to have" anymore. Since February 2024 for Gmail and Yahoo, and since 5 May 2025 for Microsoft (Outlook.com, Hotmail, Live), anyone sending more than 5,000 mails a day to personal mailboxes must have: both SPF and DKIM, a DMARC record of at least p=none that passes, and a From: domain aligned with the SPF or DKIM domain. Gmail and Yahoo also require a complaint rate below 0.3%, valid PTR records for sending IPs, and one-click unsubscribe in marketing mail (the List-Unsubscribe-Post header, RFC 8058). Google recommends keeping the complaint rate below 0.1%. According to Microsoft's page, non-compliant mail goes to the junk folder first and, if the problem persists, is rejected with 550 5.7.515. Transactional mails such as order confirmations and password resets are exempt from the unsubscribe requirement, but the authentication requirements apply to them too.
The "5,000 a day" threshold may not cover Bookstore in its first months. Apply the rules from the start anyway: the threshold counts "any day", and a campaign day can exceed it. These settings also improve deliverability for small senders.
In Production
Moving a sending stream to a subdomain is not enough on its own; reputation has to be managed separately too. Send transactional and marketing mail from separate subdomains (such as mail. and news.). A newsletter campaign's high complaint rate should not push password reset mails into spam.
Lock down domains that don't send mail. Domains like bookstore.com.tr, which Bookstore bought only for brand protection, are favourites of spoofers, because nobody watches them. example.com shows how to do it:
$ dig @1.1.1.1 example.com TXT +short
"v=spf1 -all"
"_k2n1y4vw3qtb4skdx9e7dxt97qrmmq9"
$ dig @1.1.1.1 example.com MX +short
0 .
$ dig @1.1.1.1 _dmarc.example.com TXT +short
"v=DMARC1;p=reject;sp=reject;adkim=s;aspf=s"
v=spf1 -all says "no IP may send mail for this domain", MX 0 . (null MX, RFC 7505) says "this domain does not accept mail", and DMARC p=reject says "reject everything unaligned". Together they are the right default for a domain that doesn't use mail.
Make DNS changes together with whoever runs mail. Changing SPF, deleting a DKIM selector or moving MX is not noticed immediately the way a web change is; the problem shows up only in receivers' reports or customer complaints. Someone has to watch the DMARC reports.
Read a provider's DNS requests before copying them as they are. Some providers still ask you to add their include to the apex, even though that is unnecessary once you have moved sending to a subdomain, and it eats into the budget of 10 queries.
Common Pitfalls
Adding a second SPF record: adding a separate v=spf1 TXT for a new service invalidates both records with permerror, as we saw above. Always edit the existing record.
Doing nothing because "we don't send mail": a domain without SPF and DMARC is open to others sending mail in your name. If there is no sending, publish a null SPF and p=reject.
Forgetting alignment and saying "SPF passes": SPF passing does not mean DMARC passes. Look at the dmarc= result in the Authentication-Results header and at whether header.from aligns with header.d/smtp.mailfrom.
Starting straight with p=reject: going to reject without reading reports means a forgotten system's mail (invoicing, the help desk) disappears overnight. Start with none, read the reports, then tighten.
Never rotating the DKIM key: 1024-bit keys generated in 2015 are still sitting on many domains. Put a yearly rotation on the calendar or use the provider's CNAME-based automatic rotation.
A Small Experiment
★☆☆ Open a mail you received in Gmail and look at the headers with "Show original". What are the SPF, DKIM and DMARC results in the Authentication-Results line? Are header.d and header.from aligned?
★★☆ Run spf-lookups.sh on three domains you know well. Which one is closest to 10? Which include spends the most queries?
★★★ Change the lab's DMARC record to p=reject; adkim=s; aspf=s and run the same script again. Does the mail signed with Bookstore's own DKIM still pass? If not, why, and what are your two options?
Bookstore's web, API and mail records are now in place. But writing them once is the easy part; the hard part is changing them. In the next post we cover TTL strategy, negative caching, DNSSEC's classic breaking point and managing the zone as code, so that an IP change, a move from one PaaS to another and a DNS provider migration happen without downtime.