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:

ProviderDotsSub-address tag
Gmailignored+
Yahoosignificant-
Outlook, iCloud, Proton, Fastmail, Zoho, Tuta, Yandex, Mail.ru, GMX, AOLsignificant+
Anything elsesignificantnone

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:

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.