A command-line tool that analyzes a domain's SPF and DMARC records to report its exposure to email spoofing, written in Go with zero external dependencies.
A from-scratch reimplementation of the detection logic described in
Email-Spoof-Check
(CyberCX, 2022), with several correctness and robustness fixes over the
original — see PRD.md §1.1 for the full analysis, but in short:
bounded SPF recursion (a cyclic record can no longer recurse forever),
correct RFC 7208 DNS lookup counting across a/mx/ptr/exists
alongside include/redirect, dual-stack (IPv4 and IPv6)
cloud-CIDR overlap detection, and DMARC subdomain-policy inheritance per
RFC 7489 §6.3.
SPF
- Whether a record is published at all, and whether it consumed more than the RFC 7208 §4.6.4 budget of 10 DNS lookups (which makes a record invalid for most mail clients).
- The total number of IPv4/IPv6 addresses the record permits as senders.
- Whether the record ends in a hard-fail (
-all) directive. - Any hostname in the include/redirect chain that failed to resolve.
- Overlap between SPF-permitted CIDRs and publicly-rentable cloud provider ranges (AWS, GCP, Azure, DigitalOcean, Oracle Cloud) — IP space an attacker could rent and use to send mail your SPF record would treat as authorized.
DMARC
- Whether a record is published at all.
- Policy (
p) and subdomain policy (sp), applying RFC 7489's inheritance rule whenspis not published. - Reporting coverage (
pct).
Every check becomes a Finding ranked info, warn, or crit,
rendered as human-readable text or as JSON for scripts and other tools.
When a domain's DMARC record (or its absence) falls short of a safe
minimum, the report also includes a suggested next-step record —
purely additive (e.g. adding rua reporting) when that's all that's
missing, or a minimal policy tightening (never a jump straight to
p=reject) when the current policy is weak. It always comes with a
disclaimer: it's a suggestion to review, not a directive, and mailseck
has no visibility into which of your senders are actually SPF/DKIM
aligned.
v1.0 complete: every package is implemented and tested, including integration tests run against real domains over real DNS. See PRD.md for the full requirements, architecture, and the roadmap beyond v1.0.
Requires Go 1.26 or newer.
go install github.com/mentesan/mailseck@latest
Or build from source:
git clone https://github.com/mentesan/mailseck.git
cd mailseck
make build
Releases cut with make dist (v1.3.0 onward) attach prebuilt binaries
for Linux, macOS and Windows, with a SHA256SUMS file. Check a download with
sha256sum -c SHA256SUMS --ignore-missing (or shasum -a 256 -c), and
confirm its version with mailseck --version or go version -m <binary>.
mailseck --version reports the version the Go toolchain recorded at
build time: the release tag (e.g. v1.3.0) for a release binary or
go install ...@v1.3.0; a Go pseudo-version for a build between tags;
either with +dirty when the checkout had uncommitted changes; and
devel for go run or a build without git metadata, such as from a
source tarball.
mailseck -d <domain> [flags]
| Flag | Default | Description |
|---|---|---|
-d, --domain |
(required) | Domain to analyze |
-c, --custom-ip |
Custom CIDR to flag as spoofable; repeatable | |
--refresh-ips |
false |
Force a refresh of the cached cloud provider CIDRs, ignoring the TTL |
--cache-ttl |
24h |
Validity duration of the on-disk cloud CIDR cache |
--timeout |
30s |
Overall timeout for the whole analysis (CIDR loading, SPF, and DMARC) |
--json |
false |
Emit the report as JSON instead of text |
--no-color |
false |
Disable ANSI colors even on a terminal |
--version |
false |
Print mailseck <version> and exit 0; -d is not required |
Exit code:
| Code | Meaning | Report on stdout |
|---|---|---|
0 |
Both checks completed and no crit finding was raised |
yes |
1 |
Both checks completed and at least one crit finding was raised |
yes |
2 |
The SPF or DMARC lookup failed (timeout, SERVFAIL, expired --timeout): status is error |
yes |
2 |
Invalid input (missing or bad flag, invalid domain or --custom-ip) or a recovered panic |
no |
A domain that publishes no SPF or DMARC record is a crit finding
(exit 1), not a failed run. Exit 2 is ambiguous on its own: a script
should always read stdout, and when it holds a report, decide by each
check's status field rather than by the exit code. Every failed lookup
also writes one line to stderr naming the check, the name queried, and
the error; a record that is simply absent writes nothing there.
--timeout is one budget shared by the whole run, in order: CIDR
loading, then SPF (including its include/redirect chain), then DMARC. If
SPF uses it all, DMARC ends with status error because the deadline
expired, not because its lookup failed on its own.
Analyze a domain:
$ mailseck -d example.com
Machine-readable output, for scripts or CI:
$ mailseck -d example.com --json
Flag a custom IP range (e.g. your own infrastructure) as spoofable:
$ mailseck -d example.com -c 203.0.113.0/24 -c 198.51.100.0/24
Force a refresh of the cloud provider CIDR cache, bypassing its TTL:
$ mailseck -d example.com --refresh-ips
$ mailseck -d example.com
SPF/DMARC report for example.com
[INFO] SPF record is defined
Spoofed mail is somewhat prevented.
[INFO] 0 DNS lookup(s) were made
More than 10, and the record would be invalid.
[INFO] All hostnames were resolved
An irresolvable hostname may invalidate the entire record.
[INFO] '-all' directive is in use
Mail clients know to hard fail spoofed mail.
[INFO] No common public-obtainable IP ranges exist
No cloud provider IP ranges that would allow adversaries to bypass SPF are present in the record.
[INFO] DMARC record is defined
SPF policy is less ambiguous.
[INFO] 100% of email is covered
The policy is not in a phased rollout.
[INFO] DMARC policy is active
A rejection criteria is in use or implied.
[INFO] DMARC policy is active for subdomains
A rejection criteria is in use or implied.
Severity badges ([CRIT], [WARN], [INFO]) are colored on an
interactive terminal, unless --no-color is set or the output is
redirected or piped — in which case they render as the plain labels
shown above.
When a domain's DMARC falls short of the safe minimum, a suggestion block is appended after the findings:
Suggested DMARC record:
v=DMARC1; p=quarantine; sp=quarantine; pct=100; rua=mailto:dmarc-reports@example.com
This is a suggested minimum configuration, not a directive -- confirm with whoever owns
email security for this domain before publishing it. This suggestion also tightens
enforcement: confirm every legitimate sending source (including any third-party service)
is SPF- or DKIM-aligned before publishing it, or legitimate mail may be quarantined or
rejected.
$ mailseck -d example.com --json
{
"domain": "example.com",
"tool_version": "v1.3.0",
"spf": {
"is_present": true,
"status": "found",
"error": "",
"raw_record": "v=spf1 -all",
"total_ips": 0,
"total_lookups": 0,
"has_hard_fail": true,
"irresolvable_hosts": [],
"overlaps": []
},
"dmarc": {
"is_present": true,
"status": "found",
"error": "",
"raw_record": "v=DMARC1;p=reject;sp=reject;adkim=s;aspf=s",
"policy": "reject",
"subdomain_policy": "reject",
"percentage": 100,
"rua": [],
"ruf": []
},
"findings": [
{
"code": "spf_record",
"severity": "info",
"title": "SPF record is defined",
"detail": "Spoofed mail is somewhat prevented.",
"items": []
}
],
"dmarc_suggestion": null
}(Truncated here for brevity; the real output has one entry in
findings per check described above.) tool_version is the version of
the mailseck binary that produced the report, the same value
--version prints; it is always present and never empty (devel at
worst). dmarc_suggestion is an object
with record and caveat fields, as shown in the text example above,
whenever there's a suggestion to make; it's null, as here, when the
current DMARC record already meets the safe minimum.
spf.status and dmarc.status say whether each check could be
completed, and are what automation should branch on:
status |
Meaning |
|---|---|
found |
The lookup succeeded and a valid record is published |
not_found |
The lookup was answered and no valid record exists (also: zero or several v=DMARC1 records) |
error |
The lookup itself failed; nothing is known about the record, and error carries the message |
For both spf and dmarc, these always hold:
is_presentistrueexactly whenstatusisfound.erroris a non-empty string exactly whenstatusiserror, and""otherwise — nevernull.- With any
statusother thanfound,raw_recordis""and every collection is[].
A check with status error produces a single warn finding
(spf_record or dmarc_record, titled "... record could not be
verified") and no other finding for that check; for DMARC,
dmarc_suggestion is also null, since there is nothing known to
suggest from. An SPF record that exceeds the 10-lookup budget or has a
cyclic include/redirect chain is found, and gets a crit
spf_analysis finding.
Known limitation: the Go standard library resolver reports a domain
that does not exist at all (NXDOMAIN) the same way as a domain that
exists without the record (NODATA), so a non-existent domain shows up as
SPF and DMARC not_found, with both crit findings.
Every collection field is always an array, never null — an SPF record
with no unresolved hosts serializes irresolvable_hosts as [], not
null — so a script or workflow tool never needs a special case for a
missing collection.
code is a short, stable identifier for the rule a finding evaluates
(e.g. spf_overlap, dmarc_policy). It stays the same regardless of
severity and across any future wording change to title/detail, so
automation should match on code, never on the English text. items
carries a finding's itemized detail — one line per overlapping CIDR, or
one hostname per unresolved host — instead of folding a variable-length
list into the detail sentence.
mailseck/
main.go, flags.go, validate.go // CLI entry point, flag parsing, domain validation
internal/
spf/ // SPF record resolution, RFC 7208 recursion, and lookup counting
dmarc/ // DMARC record resolution and tag parsing
cidr/ // Public cloud provider CIDR ranges, with an on-disk TTL cache
lookup/ // DNS lookup outcome (found / not_found / error) shared by spf and dmarc
report/ // Finding model, rule evaluation (Build), and text/JSON renderers
Every package has zero external dependencies — only the Go standard library. See PRD.md §7 for the full architecture rationale.
This project uses a Makefile for the common tasks:
make check # the delivery gate: fmt-check, vet, line-length, then test
make build # build the mailseck binary
make test # run the fast test suite with the race detector (no network access)
make test-integration # also run tests that hit real DNS (see below); not part of check
make lint # fmt-check + vet + line-length, without running tests
make fmt-check # fail listing files gofmt would change, or if gofmt is not in PATH
make vet # go vet ./...
make line-length # fail listing file:line for any .go line above 120 characters
make dist # release binaries + bin/SHA256SUMS, only at a release tag (see Releasing)
make clean # remove the built binary
make install # go install .
make check is the single gate a change must pass. make test runs
with -race, which needs cgo and a C compiler (on macOS, the Xcode
Command Line Tools); with CGO_ENABLED=0 or no compiler it fails rather
than silently skipping the race detector. line-length counts
characters, not bytes, so a line with multibyte characters such as §
is measured the way an editor shows it, and a tab counts as one.
A few tests are gated behind the integration build tag because they
resolve real DNS records for well-known domains (e.g. gmail.com,
cloudflare.com). They are excluded from make test and from go test ./... by default, and only run via:
make test-integration
Releases are cut by hand; make dist only builds, it never tags,
pushes or publishes.
make check
git tag -a v1.3.0 -m "v1.3.0: <summary>"
git push origin v1.3.0
make dist
gh release create v1.3.0 bin/* --verify-tag --notes-file <notes file>
make dist runs make check first, then refuses to build unless HEAD
is exactly at one vMAJOR.MINOR.PATCH tag (pre-release tags such as
v1.3.0-rc1 are refused) and the working tree is clean, untracked
files included. It recreates bin/ with one binary per platform in
PLATFORMS (CGO_ENABLED=0, -trimpath, -ldflags="-s -w"), checks
with go version -m that each one embeds exactly the tag, and writes
bin/SHA256SUMS.
The module path has no /vN suffix, so the Go toolchain only accepts
v0.x.y and v1.x.y tags as the module version: a v2.0.0 tag would
be stamped as a pseudo-version and make dist would refuse it. Moving
to v2 means changing the module path to github.com/mentesan/mailseck/v2
first.
Inspired by Email-Spoof-Check (CyberCX, 2022); no code was copied from it — see PRD.md §1.1 for why, and for the design decisions that grew out of studying it.
BSD 3-Clause. See LICENSE.