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,             # overall deadline, checked between terms
    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 add caching, share a resolver across checks, or 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. Query recording, caching, the void count and the budget are all handled in the base class.

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, or resolver and nameservers supplied together.

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 at the resolver. Counting per term 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

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.1.0.tar.gz (24.0 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.1.0-py3-none-any.whl (24.2 kB view details)

Uploaded Python 3

File details

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

File metadata

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

File hashes

Hashes for spftrace-0.1.0.tar.gz
Algorithm Hash digest
SHA256 6c88093a31052a228c459e0473967d6de2777dbdd1615a6f1c6b970093cbec6b
MD5 be92bfa2169658f12209d66c7d37eba1
BLAKE2b-256 36b423d75f1359e64ae4312c06ed86e8729f8e16063b0bacd87b5342fa439dbb

See more details on using hashes here.

Provenance

The following attestation bundles were made for spftrace-0.1.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.1.0-py3-none-any.whl.

File metadata

  • Download URL: spftrace-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 24.2 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.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 cc8e9fd21cf99aaead8e651d6df917729dec83d9619a2c1f3e5bde15978a9928
MD5 b7c4cf2efd43ca5a88bc864f94be18f2
BLAKE2b-256 ba40f56c8ca3f1d451fc03c43b6bedbc859967c6a038977383aec936a3a1fc81

See more details on using hashes here.

Provenance

The following attestation bundles were made for spftrace-0.1.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

0.2.0

2 files

0.1.1

2 files

This release

0.1.0 This release

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