SPF (Sender Policy Framework, RFC 7208) is the email-authentication primitive that says "these IPs are authorized to send mail as this domain." It's the cheapest authentication record to publish — a single TXT record at the domain root. It's also the easiest to break: a single misconfigured include: exceeds the 10-lookup limit and silently fails authentication for every receiver. This article walks the syntax in depth, the operational gotchas, and the AcelleMail-specific recipes.
The minimum useful SPF#
TXT example.com "v=spf1 -all"
This says "no one is authorized to send mail as example.com." It's correct for any domain that doesn't send email — it stops attackers from spoofing your domain in phishing. Domains you own but don't send from should have this exact record as a defensive baseline.
For an actively-sending domain, the bare minimum:
TXT example.com "v=spf1 ip4:1.2.3.4 -all"
Where 1.2.3.4 is your sending IP and -all is the strict reject for everything else.
Mechanisms — the parts of SPF#
SPF records are evaluated left-to-right; the first matching mechanism wins. The mechanisms:
| Mechanism |
What it matches |
DNS lookups |
ip4:1.2.3.4 |
a specific IPv4 |
0 |
ip4:1.2.3.0/24 |
an IPv4 range |
0 |
ip6:2001:db8::/32 |
an IPv6 range |
0 |
a |
the domain's own A/AAAA records |
1 |
a:mail.example.com |
named host's A/AAAA |
1 |
mx |
the domain's MX records |
1 (+ resolution of each MX) |
include:_spf.example.com |
include another domain's SPF |
1 (+ recursive lookups inside) |
exists:%{i}.checker.example |
DNS exists check (rarely used) |
1 |
all |
match everything (must be last) |
0 |
The key insight: include: is recursive. include:_spf.google.com triggers a lookup for _spf.google.com's SPF, which itself includes _netblocks.google.com, which includes _netblocks2.google.com, etc. Each include-chain step counts toward the 10-lookup limit.
Qualifiers — strict vs lenient#
Each mechanism has an optional prefix (qualifier):
+ (default if omitted) — pass: authorize this source
- — fail: reject mail from this source (use with all for the strict default)
~ — soft-fail: accept but mark suspect
? — neutral: don't make a determination
-all means "all unmatched IPs are rejected." ~all means "all unmatched IPs are soft-fail (accept but mark)." Most operators want -all for production once they're confident in the IP list; ~all during initial setup or migration when you're not sure all sources are listed.
The 10-DNS-lookup limit#
RFC 7208 caps SPF evaluation at 10 DNS lookups. Beyond that, receivers MUST return permerror, which is a SPF authentication failure equivalent to "no SPF record at all" — every message goes to spam.
The ones that count toward the limit:
- Each
a, mx, ptr, exists mechanism: 1 lookup.
- Each
include: mechanism: 1 lookup + the lookups inside the included record (recursively).
- Each redirect modifier: 1 lookup.
The ones that don't count:
ip4: and ip6: mechanisms: 0 lookups.
- The initial query for the SPF record itself.
A common failure pattern:
v=spf1 include:amazonses.com include:_spf.google.com include:sendgrid.net include:_spf.salesforce.com -all
This lookups: 4 includes × ~2 lookups each = ~8 lookups, plus margin. Add a fifth include: and you're past 10. Receivers permerror; deliverability cratered.
Flattening — the standard fix#
"Flattening" SPF means resolving all the include:s manually and writing the resulting IPs directly with ip4: mechanisms. Tools: dmarcian SPF Surveyor, SPF Flattening Service, or roll your own with dig:
# Resolve _spf.google.com recursively
dig +short TXT _spf.google.com
# Output: "v=spf1 include:_netblocks.google.com include:_netblocks2.google.com ..."
dig +short TXT _netblocks.google.com
# Output: "v=spf1 ip4:35.190.247.0/24 ip4:64.233.160.0/19 ..."
Flatten manually, then publish all the resolved ip4: ranges directly. Costs 0 lookups; works regardless of upstream changes.
The trade-off: when the upstream provider updates their IP ranges (Google, AWS, etc. occasionally rotate), your flattened SPF goes stale and starts rejecting legitimate mail. You need to refresh quarterly. SPF flattening services handle this automatically (paid; ~$10-30/month).
AcelleMail-specific SPF recipes#
Recipe 1 — Self-hosted, single sending IP#
Simplest case. AcelleMail running on one IP, sending directly:
TXT example.com "v=spf1 ip4:1.2.3.4 -all"
Recipe 2 — AcelleMail + Amazon SES#
When AcelleMail sends through SES (most common):
TXT example.com "v=spf1 include:amazonses.com -all"
Or, to avoid the include: lookup cost, flatten:
TXT example.com "v=spf1 ip4:54.240.0.0/18 ip4:54.231.0.0/17 ... -all"
Refresh the IP list when AWS announces SES IP-range updates (their changelog).
Recipe 3 — AcelleMail + multiple sources (transactional + marketing)#
Multi-source SPF:
TXT example.com "v=spf1 include:amazonses.com include:_spf.acellemail-self.example.com -all"
Where _spf.acellemail-self.example.com contains your own AcelleMail's sending IPs. This pattern keeps the main SPF record short while listing IPs in a sub-record.
Recipe 4 — Catch-all subdomain SPF#
For any subdomain you don't actively send from:
TXT *.example.com "v=spf1 -all"
The wildcard matches nonexistent.example.com etc. — preventing spoofing of any subdomain you don't explicitly use.
Verification#
# 1. Check the record exists
dig +short TXT example.com | grep spf
# 2. Count the DNS lookups
# Use https://mxtoolbox.com/spf.aspx — paste your domain, see lookup count.
# Should be ≤ 9 (1 less than the 10 limit, for safety margin)
# 3. Test from your sending host
cd /var/www/acellemail
php artisan tinker --execute='
$d = \Acelle\Model\SendingDomain::where("name","example.com")->first();
echo $d->verifySpf("1.2.3.4") ? "PASS" : "FAIL";
'
Per app/Model/SendingDomain.php, AcelleMail's verifySpf() uses the Mika56\SPFCheck library to evaluate authoritatively from the sending IP's perspective.
Related reading#
FAQ#
Can I have two SPF records?#
No. RFC 7208 says one SPF TXT record per domain. Multiple records cause permerror at the receiver. To combine sources, use multiple include: mechanisms or flatten into a single record.
What about SPF for Return-Path vs From: domains?#
SPF authenticates the envelope sender (the SMTP MAIL FROM, also called Return-Path). The From: header is separate — DMARC alignment requires both to come from the same domain (or both subdomains of the same parent). AcelleMail's bounce-handler returns to the configured Return-Path domain; configure SPF for that domain.
Does SPF protect against display-name spoofing?#
No. SPF authenticates the envelope sender; it has no view into the display name (the human-readable part). Display-name spoofing ("CEO Name attacker@evil.com") is mitigated by client-side filters, not SPF.
What happens if I don't have an SPF record at all?#
Most receivers treat "no SPF" as none (neutral) — accepted but no authentication boost. Gmail and Microsoft both penalize "no SPF" senders modestly. Always publish at least a minimal SPF.
SPF and DMARC alignment#
SPF authentication can pass while SPF alignment fails — and DMARC requires alignment, not just authentication.
- SPF authentication: the IP that delivered the message is in the SPF record of the envelope sender's domain.
- SPF alignment: that envelope sender's domain matches (or shares a parent with) the
From: header domain.
Example of pass-without-alignment:
From: marketing@example.com
Return-Path: bounces@bounces.acellemail-host.com
SPF for bounces.acellemail-host.com: includes the sending IP → SPF PASS
DMARC alignment: example.com vs bounces.acellemail-host.com → not aligned
DMARC verdict: FAIL
The fix: configure AcelleMail's Return-Path (envelope sender) to use a subdomain of the From: domain — e.g. bounce@bounces.example.com so the org-domain is example.com for both. Then SPF aligned + DMARC pass.
Per app/Model/SendingDomain.php, the AcelleMail bounce-handler returns to a configurable host. Set this per customer's sending domain to maintain alignment.
Common SPF debugging commands#
# 1. Resolve the SPF record cleanly
dig +short TXT example.com | grep -i spf
# 2. Check lookup count
# (use mxtoolbox.com/spf.aspx — too tedious to count manually with includes)
# 3. Check from a sending IP perspective (PHP via SPFCheck library)
php -r 'require "vendor/autoload.php";
$resolver = new \Symfony\Component\Dns\Resolver\PublicSuffixListAwareResolver();
$check = new \Mika56\SPFCheck\SPFCheck($resolver);
echo $check->isIPAllowed("1.2.3.4", "example.com") ? "PASS" : "FAIL";'
# AcelleMail's verifySpf() uses the same library internally.
When SPF resolution returns "permerror" but the record exists, mxtoolbox usually reveals which include chain blew the lookup limit.