Guide
Everything the library does, in the order you are likely to need it.
Install
The offline half and the network half are separate Go modules, so you only take on the dependencies of the part you use.
go get github.com/bakhod1r/emailx // parse, normalize, classify — zero dependencies
go get github.com/bakhod1r/emailx/dns // MX, SPF, DMARC, DKIM, BIMI, MTA-STS, ...
go get github.com/bakhod1r/emailx/smtp // mailbox probing
Go 1.26 or newer. The core module imports nothing outside the standard
library and never opens a network connection; the dns module
brings in github.com/miekg/dns, and smtp depends
on dns. The examples below use these import names:
import (
"github.com/bakhod1r/emailx"
dnsx "github.com/bakhod1r/emailx/dns"
smtpx "github.com/bakhod1r/emailx/smtp"
)
Parsing
Parse accepts a plain address or a header-style one and
returns an *Email whose parts can be inspected individually.
e, err := emailx.Parse("John.Doe+news@Gmail.com")
if err != nil {
// emailx.ErrEmpty, ErrInvalidSyntax, ErrInvalidLocalPart,
// ErrInvalidDomain, ErrLocalPartTooLong, ErrDomainTooLong
}
e.Address() // "John.Doe+news@gmail.com"
e.LocalPart() // "John.Doe+news"
e.BaseLocalPart() // "John.Doe"
e.PlusTag() // "news"
e.HasPlusTag() // true
e.Domain() // "gmail.com"
e.DomainName() // "gmail"
e.TLD() // "com"
e.Subdomain() // ""
The domain is lower-cased at parse time. Domains are
case-insensitive under RFC 5321 §2.4, and every domain-keyed lookup —
provider, disposable list, country TLD, DNS and cache keys — would
otherwise miss on mixed-case input. The local part keeps its case until
you call Normalize.
ParseAddress handles "John Doe <john@example.com>"
explicitly; Parse routes to it automatically when it sees angle
brackets. Internationalized domains convert both ways:
e, _ := emailx.Parse("user@münchen.de")
e.DomainASCII() // "xn--mnchen-3ya.de", nil
e.DomainUnicode() // "münchen.de", nil
Normalization
Normalize rewrites an address into the form that identifies
the mailbox, so two spellings of one inbox compare equal.
e, _ := emailx.Parse("John.Doe+shopping@Gmail.com")
e.Normalize()
e.Address() // "johndoe@gmail.com"
// or in one call
s, _ := emailx.Normalize("John.Doe+shopping@Gmail.com")
The rules are provider-specific, because the providers differ:
| Provider | Dots | Sub-address tag |
|---|---|---|
| Gmail | ignored | + |
| Yahoo | significant | - |
| Outlook, iCloud, Proton, Fastmail, Zoho, Tuta, Yandex, Mail.ru, GMX, AOL | significant | + |
| Anything else | significant | none |
An unknown provider gets case folding only. Stripping dots there would merge two genuinely separate mailboxes, which is a worse error than failing to merge two spellings of one.
Comparison
a, _ := emailx.Parse("john.doe@gmail.com")
b, _ := emailx.Parse("JohnDoe+shopping@gmail.com")
a.EqualNormalized(b) // true — same mailbox
a.EqualExact(b) // false — different strings
Validation
emailx.IsValid("user@example.com") // true
e.IsPossible() // cheap: a local part and a domain exist
e.IsValid() // syntax check
e.ValidateSyntax() // error explaining why, if invalid
e.ValidateDomain() // the domain has a TLD
IsValid is ValidateSyntax without the reason —
the same check, so the two cannot disagree about an address.
The accepted grammar is RFC 5322 dot-atom on both sides of the
@: atext characters in dot-separated atoms, and a
domain of letter-digit-hyphen labels, within the RFC 5321 limits of 64
octets for the local part and 253 for the domain. Leading, trailing and
consecutive dots in the local part are rejected, and a label may not start
or end with a hyphen — stricter than net/mail on both counts.
Two forms are valid per RFC 5321 but deliberately not accepted, because most receiving systems will not accept them either:
- quoted local parts —
"a b"@example.com,"with@at"@example.com - address literals —
user@[192.0.2.1],user@[IPv6:2001:db8::1]
Internationalized addresses
The plain form is ASCII-only. RFC 6531 addresses — a non-ASCII local
part, a U-label domain — have their own entry points, because delivery to
them needs a server advertising the SMTPUTF8 extension.
emailx.IsValidSMTPUTF8("денис@münchen.de") // true
e.IsValidSMTPUTF8()
e.ValidateSyntaxSMTPUTF8() // with the reason
Validate runs every offline check at once and returns a
ValidationResult. It makes no network calls, so its
MXValid, SPFValid and DMARCValid
fields stay false; dnsx.Validate(e) fills them in, and
dnsx.LookupInfo gives you the parsed records behind them.
Classification
e.IsDisposable() // throwaway-mail provider
e.DisposableProvider() // "mailinator" for mailinator.com
e.IsRole() // info@, support@, no-reply@ …
e.IsFree() // a known free consumer provider
e.Provider() // *Provider{ID, Name, Domains, Free}
e.Country() // "Uzbekistan" for a .uz address
e.Suggestion() // "user@gmail.com" for "user@gamil.com"
The disposable list carries 8,201 domains, the role list 59 prefixes, the
provider table 20 providers, and the country table 248 ccTLDs. Check a bare
domain without parsing an address with
emailx.IsDisposableDomain("mailinator.com").
IsRoleWithPrefixes takes your own list when the bundled one
is not the policy you want.
Enumerating the tables
The provider table and the disposable list can be walked, not only
tested for membership — which is what generating synthetic or test data
needs. The iter.Seq forms walk without copying; the
slice-returning forms hand back a copy you may keep and modify.
for p := range emailx.AllProviders() {
p.ID // "gmail"
p.Name // "Gmail"
p.Domains // ["gmail.com", "googlemail.com"]
p.Free // true
}
for d := range emailx.AllDisposableDomains() {
// 8,201 domains, no copy made
}
emailx.Providers() // []*Provider, sorted by ID
emailx.ProviderByID("gmail") // one provider, or nil
emailx.ProviderForDomain("gmail.com")
emailx.ProviderDomains() // every mapped domain, sorted
emailx.DisposableDomains() // []string, a fresh copy
emailx.DisposableDomainCount() // 8201
DNS records
dnsx.LookupInfo is the main entry point. It fetches MX, SPF,
DMARC and DKIM concurrently, so the whole call costs about one round trip.
info := dnsx.LookupInfo("example.com")
info.HasMX() // MX records exist
info.MX // []MXRecord{{Host, Priority}}, priority order
info.MXErr // the MX lookup error, if any
info.SPF // *SPF
info.DMARC // *DMARC
info.DKIM // *DKIM
info.IsProtected() // -all, enforcing DMARC, and a live key
Every predicate is nil-safe, so a partially failed lookup does not panic.
Pass DKIM selectors as trailing arguments to narrow the probe:
LookupDNSInfo("example.com", "selector1").
Individual boolean checks — HasMX, HasSPF,
HasDMARC, HasDKIM — exist for the cases where the
parsed record is genuinely not needed.
SPF
LookupSPF resolves the record and everything it pulls in
through include: and redirect=.
s := dnsx.LookupSPF("example.com")
s.Found // a v=spf1 record exists
s.All // SPFFail "-", SPFSoftFail "~", SPFNeutral "?", SPFPass "+"
s.IsStrict() // All == "-"
s.Mechanisms // []SPFMechanism{Qualifier, Name, Value}
s.Modifiers // redirect, exp, unknown name=value
s.Includes // every domain reached, in resolution order
s.Lookups // DNS-querying terms consumed
s.TooManyLookups // over the RFC 7208 limit of 10
s.Errors // loops, missing records, malformed terms
The lookup count is the part most checkers miss.
RFC 7208 §4.6.4 allows ten DNS-querying mechanisms per evaluation:
include, a, mx, ptr,
exists and redirect. Exceed it and receivers
return permerror and stop evaluating — the record is published, looks
correct, and protects nothing.
Include loops and over-deep chains are detected and reported in
Errors rather than hanging. A terminating all ends
evaluation, so terms after it are not counted, and redirect= is
only followed when no all is present.
DMARC
d := dnsx.LookupDMARC("example.com")
d.Policy // DMARCNone, DMARCQuarantine, DMARCReject
d.SubdomainPolicy // sp=, defaulting to Policy
d.Percent // pct=, defaulting to 100
d.RUA, d.RUF // report destinations
d.SPFAlignment // aspf: AlignmentRelaxed or AlignmentStrict
d.DKIMAlignment // adkim
d.Tags // every tag as parsed, including unknown ones
d.IsEnforcing() // reject or quarantine, at pct=100
IsEnforcing is the question worth asking.
p=none publishes a policy that reports and blocks nothing, and
p=reject; pct=20 blocks one message in five. Both satisfy a
naive "has DMARC?" check.
Invalid tags are recorded in Errors and leave the default in
place rather than corrupting the result.
DKIM
DNS offers no way to discover a selector, so LookupDKIM
probes CommonDKIMSelectors — 18 selectors covering Google
Workspace, Microsoft 365, Amazon SES, Mailchimp, Postmark, SendGrid,
Mailgun, Zoho, Proton and the usual self-hosted defaults — and stops at the
first hit.
k := dnsx.LookupDKIM("example.com")
k := dnsx.LookupDKIM("example.com", "selector1", "selector2") // or your own
k := dnsx.LookupDKIMSelector("example.com", "google") // exactly one
k.Found // a key record was found
k.Selector // which selector answered
k.PublicKey // p=, with whitespace stripped
k.Revoked // p= is empty: the key was withdrawn
k.Testing // t=y: receivers must ignore failures
k.KeyType // k=, defaulting to "rsa"
k.HashAlgos // h=
A record with an empty p= is a published revocation, not a
working key, and t=y marks a key still in testing. Neither
counts towards IsProtected().
Transport security
dnsx.HasMTASTS("example.com").Enabled // MTA-STS policy published
dnsx.HasTLSRPT("example.com").Enabled // TLS reporting configured
dnsx.HasBIMI("example.com") // LogoURL, VMC
dnsx.HasDNSSEC("example.com") // DNSKEY records exist
CheckDomainHealth gathers all of it plus the authentication
records in one concurrent pass:
h := dnsx.CheckDomainHealth("example.com")
h.MX, h.SPF, h.DKIM, h.DMARC
h.MTASTS, h.TLSRPT, h.BIMI, h.DNSSEC
h.DNS // the parsed records behind those booleans
h.Protected() // whether the setup actually works
Risk and deliverability
Both score the parsed policies, not the presence of records, and both explain themselves.
r := dnsx.CheckRisk(e)
r.Level // RiskLow, RiskMedium, RiskHigh
r.Score // 0–100+
r.Signals // []Signal{Impact, Description}
d := dnsx.CheckDeliverability(e.Domain())
d.Score // 0–100
d.SPFStrict // the record ends in -all
d.SPFBroken // over the lookup limit, so not evaluated
d.DMARCEnforcing // p=reject or quarantine at pct=100
d.Reasons // one line per deduction
An SPF record over the lookup limit is scored no better than having none, because that is how receivers treat it. Domain-hygiene signals weigh less than signals about the address itself: a spoofable domain means the sender is hard to authenticate, not that this particular address is fraudulent.
Analyze returns everything at once in a
*Profile, using a single round of lookups:
p, err := dnsx.Analyze("user@example.com")
p.Valid, p.Disposable, p.Role, p.Free
p.MX, p.SPF, p.DKIM, p.DMARC, p.Protected
p.DNS, p.BIMI, p.MTASTS, p.TLSRPT, p.DNSSEC
p.Risk, p.Deliverability, p.Suggestion
SMTP verification
res := smtpx.Verify("user@example.com", smtpx.Options{
HelloName: "mail.yourdomain.com",
FromAddress: "probe@yourdomain.com",
Limiter: limiter,
Timeout: 10 * time.Second,
})
res.Status // SMTPValid, SMTPInvalid, SMTPCatchAll, SMTPUnknown
res.MXHost // which server answered
res.Code // the SMTP reply code for RCPT TO
res.CatchAll // a random address was accepted too
res.Greylisted // 4xx: retry later
res.STARTTLS // the server advertised STARTTLS
The probe walks the MX records in priority order until one completes a
conversation, runs MAIL FROM and RCPT TO, then
repeats with a random local part. A server that accepts that too is a
catch-all, so acceptance of the real address proves nothing — which is why
the status becomes catch-all rather than valid.
Read this before depending on the result. Most cloud
providers block outbound port 25, so you will get unknown
everywhere unless the network allows it. Unthrottled probing gets the
sending IP blocklisted — always pass a Limiter. Many large
providers answer every probe identically by design. A 4xx
reply is greylisting and is reported as unknown, never as
invalid; retry later before concluding anything.
HelloName should be a hostname that resolves back to the
connecting IP; many servers refuse otherwise. An empty
FromAddress sends the standard null sender
<>, which some servers reject.
Resolvers and caching
Every network call has a Context variant. The non-context
forms apply a package-wide deadline:
dnsx.SetDefaultTimeout(3 * time.Second)
dnsx.DefaultTimeout()
Choosing a resolver
// The system resolver (default).
dnsx.SetResolver(nil)
// A specific DNS server, through the system resolver machinery.
dnsx.SetResolver(dnsx.NewResolver("1.1.1.1:53", 2*time.Second))
// Direct queries: exposes record TTLs and rejoins chunked TXT records.
dnsx.SetResolver(dnsx.NewDNSResolver("1.1.1.1:53", 2*time.Second))
NewDNSResolver is the one to prefer when caching. The system
resolver hides TTLs, so a cache in front of it can only use a fixed window.
It also rejoins TXT records that arrive as 255-byte chunks, which long SPF
and DKIM records require in order to parse.
Any type implementing Resolver works, which is also how the
library's own tests avoid the network.
Caching
cache := dnsx.EnableCache(5 * time.Minute)
cache.SetMaxSize(50000)
cache.Len()
cache.Purge()
The cache stores failures as well as answers, so a batch of addresses on
a dead domain costs one lookup rather than one per address. It collapses
concurrent lookups of the same name into a single upstream query, so a burst
of requests for one domain does not become a burst of DNS traffic. With a
TTLResolver underneath, each entry expires at the record's own
TTL, clamped to [MinCacheTTL, ttl]. It holds at most
DefaultCacheSize (10,000) entries unless you say otherwise.
Rate limiting
limiter := smtpx.NewRateLimiter(5, 10) // 5/s, bursts of 10
opts := smtpx.Options{Limiter: limiter}
Share one limiter across every probe. Limiter is a
single-method interface (Wait(ctx) error), so
golang.org/x/time/rate satisfies it directly.
Batches
emails := emailx.ParseMany(inputs)
norms := emailx.NormalizeMany(inputs)
uniq := emailx.Unique(inputs) // deduplicated by normalized form
results := emailx.ValidateMany(inputs, emailx.WithConcurrency(20)) // offline
dnsx.EnableCache(5 * time.Minute)
checked := dnsx.ValidateMany(inputs, 20) // offline checks plus MX, SPF, DMARC
Enable the cache before any DNS batch. Without it, every address on the same domain repeats the same queries.
EmailSet and EmailIndex hold parsed addresses
for lookup by domain or provider:
idx := emailx.NewIndex()
idx.Add(e)
idx.Exists("user@example.com")
idx.FindByDomain("example.com")
idx.FindByProvider("gmail")
Privacy
e.Mask() // "j******e@example.com"
e.HashSHA256() // stable hash of the normalized address
e.HashSHA512()
e.Fingerprint(emailx.WithSecret("pepper")) // keyed, so it cannot be
// reversed by dictionary
Hash the normalized form, so two spellings of one mailbox produce one
hash. A plain hash of an email address is reversible by brute force — use
Fingerprint with a secret when the values leave your system.
JSON and SQL
Email implements json.Marshaler,
encoding.TextMarshaler, sql.Scanner and
driver.Valuer, so it drops into structs and database columns
directly.
type User struct {
Email emailx.Email `json:"email"`
}
var e emailx.Email
row.Scan(&e)
Regenerating data
generated.go holds the bundled tables and is checked in.
Refresh it from upstream when the lists move:
go generate ./... # fetches current lists
go run ./internal/generator -offline # rebuild without network
The generator refuses to shrink a table — a broken upstream fetch keeps the existing data rather than silently emptying it — and never lets a known provider domain be written into the disposable list.