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.

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.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

spftrace-0.2.0.tar.gz (30.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

spftrace-0.2.0-py3-none-any.whl (28.1 kB view details)

Uploaded Python 3

File details

Details for the file spftrace-0.2.0.tar.gz.

File metadata

  • Download URL: spftrace-0.2.0.tar.gz
  • Upload date:
  • Size: 30.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for spftrace-0.2.0.tar.gz
Algorithm Hash digest
SHA256 f07373bf961f9a68cafff76c064c223b3d223e3a4641d45ae2f0dda2b424775f
MD5 878d150d37d74282e9fe40ce63a27cc0
BLAKE2b-256 0c0061202ca6779176ee75d9c484c88c4ffa424c626c0685070dfd2c62e93600

See more details on using hashes here.

Provenance

The following attestation bundles were made for spftrace-0.2.0.tar.gz:

Publisher: release.yml on smck83/spftrace

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file spftrace-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: spftrace-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 28.1 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for spftrace-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 8d08d18999a1fce01660c33db4cdbc7b01d976dd37d41a8b675ef83a734b4b2c
MD5 144f23f6073a4c71c79198380a634a3d
BLAKE2b-256 a02ab5cdb7a4b12ee257cc45b53a64de5f74a3c9c08e5cc5a02f498e8922e3f7

See more details on using hashes here.

Provenance

The following attestation bundles were made for spftrace-0.2.0-py3-none-any.whl:

Publisher: release.yml on smck83/spftrace

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.2.0 This release

2 files

0.1.1

2 files

0.1.0

2 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