Skip to main content

spftrace

An RFC 7208 SPF evaluator that shows its working.

Most SPF libraries answer pass or fail and throw the reasoning away. spftrace returns the reasoning: every DNS query with its rcode and timing, every macro expansion before and after, every mechanism with its qualifier and why it did or did not match, the running DNS-term and void-lookup counters, and the exact term that broke a limit.

It is a library. There is no web UI here, and no framework dependency. Build your own CLI, API or service on top.

  • Own implementation of check_host(), not a wrapper around another evaluator
  • Passes all 203 cases of the official openspf.org RFC 7208 test suite
  • Async core with a blocking convenience wrapper
  • One runtime dependency: dnspython
  • Reads no environment variables and no system resolver config. Configuration is explicit, so the consuming application stays in charge

Install

pip install spftrace

Use

import spftrace

result = spftrace.check("203.0.113.1", "user@example.com")

print(result.verdict)          # pass, fail, softfail, neutral, none,
                               # permerror or temperror
print(result.dns_terms_used)   # against the RFC limit of 10
print(result.to_dict())        # JSON-safe, with the full event trace

From async code, await the async form instead. Calling check() inside a running event loop raises SpfUsageError telling you so, rather than a confusing asyncio error from several frames down.

result = await spftrace.acheck("203.0.113.1", "user@example.com")

In FastAPI

from fastapi import FastAPI
import spftrace

app = FastAPI()

@app.get("/check")
async def check(ip: str, sender: str):
    result = await spftrace.acheck(
        ip, sender, nameservers=["1.1.1.1"], max_queries=75
    )
    return result.to_dict()

Do not let users pass arbitrary resolver addresses through to nameservers. That turns your service into an SSRF-ish DNS proxy. Offer a fixed set of resolvers and map the user's choice to one server side.

Options

await spftrace.acheck(
    ip,
    sender,
    helo="mail.example.net",     # defaults to the sender domain
    policy="v=spf1 -all",        # evaluate this instead of looking one up
    nameservers=["192.0.2.53"],  # defaults to 8.8.8.8
    timeout=5.0,                 # per-query DNS timeout
    max_queries=75,              # hard cap on real lookups
    time_limit=20.0,             # deadline, checked between terms and before
                                 # every DNS query
    receiver="mta01",            # value of the %{r} macro
    audit=False,                 # see below
)

policy= evaluates a record you paste in rather than one published in DNS, which is how you test a change before shipping it.

Bring your own resolver

Pass a resolver instead of nameservers to point at your own DNS, or to test with no network at all.

from spftrace import Evaluator, Limits, ZoneResolver

zone = {"e.com": [("TXT", "v=spf1 ip4:1.2.3.0/24 -all")]}
result = await Evaluator(ZoneResolver(zone), Limits()).evaluate("1.2.3.4", "a@e.com")

Subclass BaseResolver and implement async _lookup(name, rtype) -> (rcode, answers) for anything else. That method is the whole contract: return the rcode and the answer strings, and let the library do the rest.

A resolver is transport only. It holds nameservers, a timeout and a socket. It holds no counters, no cache and no trace. Everything scoped to a single check lives in an EvaluationSession that evaluate() builds fresh each time, so one resolver can serve many checks, including concurrent ones, without leaking state between them.

resolver = LiveResolver(["192.0.2.53"])   # build once, reuse for the process

async def check(ip, sender):
    return await spftrace.acheck(ip, sender, resolver=resolver)

This is a change in 0.2.0. Before it, the query budget, the void count, the query trace and the DNS cache all lived on the resolver, so reusing one carried a previous check's state into the next and could turn a passing message into a permerror. See CHANGELOG.md.

The lookup cache is deliberately scoped to one evaluation. Within a check it still removes duplicate lookups, which is where nearly all the benefit is. Across checks it would have no TTL, and serving a stale SPF record is an authentication error rather than a performance detail. A shared TTL-aware cache may arrive later as an explicit opt-in.

Errors are verdicts

RFC outcomes are never exceptions. A malformed record, a lookup-limit breach, an exhausted query budget and a DNS timeout all come back as a permerror or temperror verdict with the reason in the trace. You do not have to wrap a check in try just to survive a hostile zone.

SpfUsageError is the exception you may see, and it always means the calling code is wrong: check() from inside an event loop, resolver and nameservers supplied together, or a configuration value out of range such as a non-positive time_limit.

Audit mode

The 10-term limit means evaluation stops at the eleventh lookup, so neither a real MTA nor a normal check can tell you how many lookups an over-limit record actually needs. audit=True keeps counting past the limit and reports the true figure.

The verdict is still forced to permerror. Visibility changes; the answer never does. A matching mechanism sitting past the limit does not become a pass.

Command line

spftrace 203.0.113.1 user@example.com
spftrace 203.0.113.1 user@example.com --json
spftrace 203.0.113.1 user@example.com --policy "v=spf1 include:_spf.example.net -all"
spftrace 203.0.113.1 user@example.com --dns 1.1.1.1 --audit

--dns also reads SPFTRACE_DNS. That environment variable is a CLI convenience only; the library itself never reads it.

Result

Attribute Meaning
verdict (alias result) the RFC 7208 result string
explanation expanded exp= text, on fail only
trace ordered Event log with recursion depth
queries every DNS query: name, type, rcode, answers, ms, void, source
dns_terms_used terms consumed against the limit of 10
void_lookups_used void lookups against the limit of 2
elapsed_ms wall time for the evaluation
warnings non-fatal notes about the record

to_dict() is the stable JSON contract and carries schema_version. Additive keys will not bump it; a change consumers must notice will.

The trace shows every lookup, not just the ones that decided the verdict. A mx term emits a dns_lookup event for the MX query and one for each exchange it resolves, so a mechanism that did not match still tells you which hosts were checked and what they resolved to. Only the exchanges actually queried appear: mx stops at the first match, and the trace does not imply work that never happened. The address family follows the connecting IP, per RFC 7208 section 5.3 — an IPv4 client produces A lookups only, an IPv6 client AAAA only, never both.

Limits enforced

  • 10 DNS terms over include, a, mx, ptr, exists and redirect, not ip4, ip6 or all
  • 2 void lookups, counted once per real lookup by the evaluation session, and enforced the moment the third one returns rather than at the end of the mechanism. An mx pointing at a dozen dead exchanges stops after the third, which is the point of the limit. Counting per term instead double counts: every enclosing include re-counts its children, and a single void three includes deep became a false permerror
  • 10 MX records per mx, 10 PTR names per ptr
  • exp and %{p} do DNS but do not count against the term limit
  • A separate hard cap on real queries, 75 by default, because the term limit counts terms and not lookups: ten mx terms with ten MX records each is 10 terms but 111 queries. The cap is per evaluation, so it resets for every message rather than draining over the life of a resolver

Tests

pip install -e ".[test]"
pytest

The RFC 7208 corpus is fetched from a pinned commit and its sha256 is verified, so the gate cannot shift underneath you. It is not vendored. pytest --rfc-strict fails rather than skips when the corpus cannot be fetched; CI uses it. SPFTRACE_RFC_CORPUS=/path/to/rfc7208-tests.yml runs the suite offline, still hash-checked.

Licence

MIT.

Metadata

Release files for spftrace 0.2.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for spftrace 0.2.1
File Size Uploaded
spftrace-0.2.1.tar.gz 32.2 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for spftrace 0.2.1
File Interpreter ABI Platform
spftrace-0.2.1-py3-none-any.whl Python 3 none any Details

Total release size: 60.9 kB

Release files / spftrace-0.2.1.tar.gz

Download URL spftrace-0.2.1.tar.gz
Size 32.2 kB
Tags Source
SHA-256 checksum
How to use checksums
1e26c613a9911a5e60d508f5fe095d059b0bbcb96d33fe6c092fc34ba1106579
BLAKE2b-256 checksum
How to use checksums
dcf7da2850a6ab64faad308f9ad098b9416ae88f27209659fbd7a8736d327135
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 11, 2026.

Transparency log

Release files / spftrace-0.2.1-py3-none-any.whl

Download URL spftrace-0.2.1-py3-none-any.whl
Size 28.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
8d72673fa5b06b3b899cbea2ed3d10ecdde727a9dd7ccc82263be9ee78819352
BLAKE2b-256 checksum
How to use checksums
722f3ce4f2707e6a0641f2d6772dd93893527b27e9ec27116ba7b1957bc56567
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 11, 2026.

Transparency log

Release history Release notifications | RSS feed

0.3.0

2 release files

This release

0.2.1 This release

2 release files

0.2.0

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page