Email Deliverability Testing: SPF, DKIM, DMARC, and Spam Score Validation
Writing the email and ensuring it sends is only half the job. The other half is ensuring it actually reaches the inbox. Email deliverability testing validates that your DNS configuration, email headers, and content won't cause your emails to be rejected or filtered as spam.
What Affects Deliverability
- SPF — Sender Policy Framework: authorizes which servers can send from your domain
- DKIM — DomainKeys Identified Mail: cryptographically signs emails to verify authenticity
- DMARC — Domain-based Message Authentication: policy for SPF/DKIM failures
- Spam score — content and header analysis by filters like SpamAssassin
- IP reputation — whether your sending IP is on blocklists
- Bounce rate — high bounce rates hurt sender reputation
SPF Validation
SPF is a DNS TXT record that lists authorized sending IPs for your domain.
yourdomain.com. TXT "v=spf1 include:mailgun.org include:sendgrid.net ~all"Test SPF programmatically:
# Check SPF record
dig TXT yourdomain.com <span class="hljs-pipe">| grep spf
<span class="hljs-comment"># Expected output
yourdomain.com. 300 IN TXT <span class="hljs-string">"v=spf1 include:sendgrid.net ~all"Node.js SPF check:
import dns from 'dns/promises'
async function validateSPF(domain) {
const records = await dns.resolveTxt(domain)
const spf = records.flat().find(r => r.startsWith('v=spf1'))
if (!spf) throw new Error(`No SPF record found for ${domain}`)
// Validate structure
if (!spf.includes('~all') && !spf.includes('-all')) {
throw new Error('SPF record missing fail policy (~all or -all)')
}
return spf
}
// In tests
test('SPF record exists and is valid', async () => {
const spf = await validateSPF('yourdomain.com')
expect(spf).toMatch(/v=spf1/)
expect(spf).toMatch(/include:sendgrid\.net|include:mailgun\.org/)
})DKIM Validation
DKIM uses a public key in DNS to verify the email signature.
# Check DKIM for selector "mail"
dig TXT mail._domainkey.yourdomain.com
<span class="hljs-comment"># Expected
mail._domainkey.yourdomain.com. TXT <span class="hljs-string">"v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOC..."async function validateDKIM(domain, selector = 'mail') {
const dkimDomain = `${selector}._domainkey.${domain}`
const records = await dns.resolveTxt(dkimDomain)
const dkim = records.flat().join('')
if (!dkim.includes('v=DKIM1')) {
throw new Error(`No DKIM record at ${dkimDomain}`)
}
if (!dkim.includes('p=')) {
throw new Error('DKIM record missing public key (p=)')
}
return dkim
}DMARC Validation
DMARC builds on SPF and DKIM, specifying what to do when they fail.
dig TXT _dmarc.yourdomain.com
# Expected
_dmarc.yourdomain.com. TXT <span class="hljs-string">"v=DMARC1; p=quarantine; rua=mailto:dmarc@yourdomain.com"async function validateDMARC(domain) {
const records = await dns.resolveTxt(`_dmarc.${domain}`)
const dmarc = records.flat().find(r => r.startsWith('v=DMARC1'))
if (!dmarc) throw new Error(`No DMARC record for ${domain}`)
// Policy should not be 'none' in production
if (dmarc.includes('p=none')) {
console.warn('DMARC policy is "none" — no enforcement')
}
return dmarc
}Spam Score Testing with SpamAssassin
SpamAssassin scores emails from 0 (clean) to 10+ (spam). Most email providers use a threshold of 5.
Docker SpamAssassin
docker run -d -p 783:783 instantlinux/spamassassinTest Email Spam Score
# Pipe an .eml file through SpamAssassin
<span class="hljs-built_in">cat test-email.eml <span class="hljs-pipe">| docker <span class="hljs-built_in">exec -i spamassassin spamassassin -t 2>&1 <span class="hljs-pipe">| grep <span class="hljs-string">"X-Spam-Score"Node.js integration:
import { execSync } from 'child_process'
import fs from 'fs'
import nodemailer from 'nodemailer'
async function getSpamScore(emailContent) {
// Write email to temp file
const tmpFile = `/tmp/test-email-${Date.now()}.eml`
fs.writeFileSync(tmpFile, emailContent)
try {
const result = execSync(
`cat ${tmpFile} | spamassassin -t 2>&1`,
{ encoding: 'utf8' }
)
const scoreMatch = result.match(/X-Spam-Score: ([\d.]+)/)
return scoreMatch ? parseFloat(scoreMatch[1]) : null
} finally {
fs.unlinkSync(tmpFile)
}
}
test('welcome email spam score is acceptable', async () => {
const emailContent = buildWelcomeEmail('user@example.com')
const score = await getSpamScore(emailContent)
expect(score).toBeLessThan(3)
}, 30000)Common Spam Triggers
Avoid these in your email content:
| Pattern | Risk |
|---|---|
| ALL CAPS subject | High |
Excessive !!! |
High |
| "Click here" as link text | Medium |
| Image-only emails (no text) | High |
| Mismatched link URLs | High |
| Missing unsubscribe link | High |
style="display:none" hidden text |
High |
Automated Deliverability CI Check
# .github/workflows/email-deliverability.yml
name: Email Deliverability Check
on:
schedule:
- cron: '0 9 * * 1' # Every Monday
workflow_dispatch:
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check SPF record
run: |
SPF=$(dig TXT ${{ vars.EMAIL_DOMAIN }} | grep spf)
if [ -z "$SPF" ]; then exit 1; fi
echo "SPF: $SPF"
- name: Check DKIM
run: |
DKIM=$(dig TXT mail._domainkey.${{ vars.EMAIL_DOMAIN }})
if [ -z "$DKIM" ]; then exit 1; fi
echo "DKIM found"
- name: Check DMARC
run: |
DMARC=$(dig TXT _dmarc.${{ vars.EMAIL_DOMAIN }} | grep DMARC1)
if [ -z "$DMARC" ]; then exit 1; fi
echo "DMARC: $DMARC"
- name: Check blocklists
run: |
IP=$(dig A mail.${{ vars.EMAIL_DOMAIN }} | grep -oP '\d+\.\d+\.\d+\.\d+' | head -1)
# Check MXToolbox-style
echo "Sending IP: $IP"External Deliverability Tools
For deeper analysis beyond DNS records:
- mail-tester.com — send a test email, get a deliverability score
- MXToolbox — blocklist checks, DNS record validation
- Google Postmaster Tools — spam rate, IP reputation for Gmail
- Microsoft SNDS — reputation data for Outlook/Hotmail
Summary
Email deliverability testing has two layers: DNS validation (SPF, DKIM, DMARC) which you can automate in CI, and content/reputation testing which requires periodic manual checks or paid tools. Automate the DNS checks on a schedule — they're cheap to run and catch configuration drift before it affects delivery rates.