Email domain checks in Go
· 8 min read · Tutorials
To check email domains in Go, parse the address with net/mail, reduce the host to its registrable domain with golang.org/x/net/publicsuffix, then ask a classification API whether the address is disposable, randomized or otherwise spam, using an http.Request bound to a context with a short timeout. Cache verdicts for a few minutes, back off when the API returns 429, and allow the signup whenever the check itself fails. The package below does all of that with one dependency outside the standard library.
The code needs Go 1.22 or newer for the method-based ServeMux pattern.
What runs locally and what doesn't
Some checks are cheap and deterministic, so they happen in-process before any network call:
- Syntax.
mail.ParseAddresshandles RFC 5322 parsing. We also reject anything where the parsed address differs from the input, which dropsJane <jane@acme.com>and stray whitespace inside the string. - Registrable domain.
mail.eu.acme.co.ukbecomesacme.co.uk. This is what you key allowlists and per-company counters on. eTLD+1 explained covers why the naive "last two labels" approach breaks on.co.uk. - Impossible hosts. IP literals, single labels and TLDs that aren't on the Public Suffix List (
.local,.test, typos like.cmo) fail here and never cost an API call.
Whether a domain is disposable or the local part looks machine-generated is a different question. Those lists change weekly, so that part goes to the API. If you want to maintain the list yourself, why disposable email blocklists go stale explains what you're signing up for.
The publicsuffix gotcha: private entries are included
publicsuffix.EffectiveTLDPlusOne("foo.github.io") returns foo.github.io, not github.io. The x/net/publicsuffix package compiles in the whole Public Suffix List, including the private section where companies like GitHub, Google (blogspot.com) and Amazon register their hosting domains as suffixes. That is correct for cookie scoping. It is not how pingutil keys its results. We parse with private domains turned off, so every *.github.io address maps to github.io. Unmanaged TLDs behave differently too: EffectiveTLDPlusOne("foo.cromulent") returns foo.cromulent with no error, while pingutil rejects the input with a 400.
If your local key and the API's key disagree, your allowlist and cache won't line up with the API's domain field. The PublicSuffix function reports whether the matched rule is ICANN-managed, so the fix is a short loop: when the rule is private, look for the ICANN suffix inside it.
// emailcheck/domain.go
package emailcheck
import (
"fmt"
"net"
"net/mail"
"strings"
"golang.org/x/net/idna"
"golang.org/x/net/publicsuffix"
)
// Parse returns the lowercased address and its registrable domain.
// It rejects display-name forms like "Jane <jane@acme.com>".
func Parse(raw string) (email, domain string, err error) {
raw = strings.TrimSpace(raw)
addr, err := mail.ParseAddress(raw)
if err != nil || addr.Address != raw {
return "", "", fmt.Errorf("not a bare address: %q", raw)
}
at := strings.LastIndexByte(raw, '@')
domain, err = RegistrableDomain(raw[at+1:])
if err != nil {
return "", "", err
}
return strings.ToLower(raw), domain, nil
}
// RegistrableDomain returns eTLD+1 using ICANN suffixes only, matching how
// pingutil keys results: foo.github.io -> github.io.
func RegistrableDomain(host string) (string, error) {
host = strings.TrimSuffix(strings.ToLower(host), ".")
host = strings.TrimPrefix(host, "www.")
if strings.HasPrefix(host, "[") || net.ParseIP(host) != nil {
return "", fmt.Errorf("IP literal: %q", host)
}
ascii, err := idna.Lookup.ToASCII(host)
if err != nil {
return "", err
}
suffix := icannSuffix(ascii)
if suffix == "" || ascii == suffix {
return "", fmt.Errorf("no registrable domain in %q", ascii)
}
rest := strings.TrimSuffix(ascii, "."+suffix)
return rest[strings.LastIndexByte(rest, '.')+1:] + "." + suffix, nil
}
func icannSuffix(domain string) string {
for d := domain; d != ""; {
ps, icann := publicsuffix.PublicSuffix(d)
if icann {
return ps
}
i := strings.IndexByte(ps, '.')
if i < 0 {
return "" // unmanaged TLD: .local, .test, typos
}
d = ps[i+1:] // private rule like github.io: look inside it
}
return ""
}
idna.Lookup.ToASCII converts münchen.de to xn--mnchen-3ya.de, matching the punycode form the API returns in domain. Which mode you need depends on the job: private-aware for anything cookie or tenant related, ICANN-only when you want to match pingutil.
The client: typed structs, timeout, cache, backoff
The structs only declare the fields this code reads; encoding/json ignores the rest. Nullable fields such as verdict.reason are pointers so null decodes cleanly. The full response is documented in the API reference.
// emailcheck/emailcheck.go
// Package emailcheck screens signup emails with the pingutil lookup API.
package emailcheck
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"sync"
"time"
)
const endpoint = "https://pingutil.com/v1/lookup"
type Verdict struct {
IsSpam bool `json:"is_spam"`
LowTrust bool `json:"low_trust"`
Reason *string `json:"reason"` // null when nothing was flagged
}
type LookupResponse struct {
Domain string `json:"domain"`
Category string `json:"category"`
Verdict Verdict `json:"verdict"`
EmailSignals *struct {
IsRandomized bool `json:"is_randomized"`
RandomnessScore int `json:"randomness_score"`
IsRoleBased bool `json:"is_role_based"`
} `json:"email_signals"`
Meta struct {
RequestID string `json:"request_id"`
} `json:"meta"`
}
// APIError is pingutil's error body: {"error", "message", "retry_after_seconds"}.
type APIError struct {
Status int `json:"-"`
Code string `json:"error"`
Message string `json:"message"`
RetryAfter int `json:"retry_after_seconds"`
}
func (e *APIError) Error() string {
return fmt.Sprintf("pingutil %d %s: %s", e.Status, e.Code, e.Message)
}
type Decision struct {
Allow bool
Checked bool // false means we failed open
Reason string // verdict reason, or why the check didn't run
}
type entry struct {
d Decision
expires time.Time
}
type Client struct {
APIKey string
HTTP *http.Client
Timeout time.Duration
TTL time.Duration
Allowlist map[string]bool // registrable domains that skip the API
mu sync.Mutex
cache map[string]entry
backoffUntil time.Time
}
func New(apiKey string) *Client {
return &Client{
APIKey: apiKey,
HTTP: &http.Client{Timeout: 5 * time.Second},
Timeout: 1500 * time.Millisecond,
TTL: 10 * time.Minute,
Allowlist: map[string]bool{},
cache: map[string]entry{},
}
}
func (c *Client) Check(ctx context.Context, raw string) Decision {
email, domain, err := Parse(raw)
if err != nil {
return Decision{Allow: false, Checked: true, Reason: "invalid_email"}
}
if c.Allowlist[domain] {
return Decision{Allow: true, Checked: true, Reason: "allowlisted"}
}
c.mu.Lock()
e, hit := c.cache[email]
backoff := time.Now().Before(c.backoffUntil)
c.mu.Unlock()
if hit && time.Now().Before(e.expires) {
return e.d
}
if backoff {
return Decision{Allow: true, Reason: "rate_limited"}
}
res, err := c.lookup(ctx, email)
var apiErr *APIError
switch {
case err == nil:
d := Decision{Allow: !res.Verdict.IsSpam, Checked: true}
if res.Verdict.Reason != nil {
d.Reason = *res.Verdict.Reason
}
c.store(email, d)
return d
case errors.As(err, &apiErr) && apiErr.Status == http.StatusBadRequest:
return Decision{Allow: false, Checked: true, Reason: "invalid_email"}
case errors.As(err, &apiErr) && apiErr.Status == http.StatusTooManyRequests:
wait := apiErr.RetryAfter
if wait <= 0 {
wait = 60
}
c.mu.Lock()
c.backoffUntil = time.Now().Add(time.Duration(wait) * time.Second)
c.mu.Unlock()
return Decision{Allow: true, Reason: "rate_limited"}
case errors.Is(err, context.DeadlineExceeded):
return Decision{Allow: true, Reason: "timeout"}
default:
slog.Error("email check failed", "domain", domain, "err", err)
return Decision{Allow: true, Reason: "unavailable"}
}
}
func (c *Client) lookup(ctx context.Context, email string) (*LookupResponse, error) {
ctx, cancel := context.WithTimeout(ctx, c.Timeout)
defer cancel()
body, _ := json.Marshal(map[string]any{"input": email, "skip_enrichment": true})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.APIKey)
req.Header.Set("Content-Type", "application/json")
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
apiErr := &APIError{Status: resp.StatusCode}
_ = json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(apiErr)
return nil, apiErr
}
var out LookupResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("decode: %w", err)
}
return &out, nil
}
func (c *Client) store(email string, d Decision) {
c.mu.Lock()
defer c.mu.Unlock()
if len(c.cache) >= 10_000 {
clear(c.cache) // crude bound; swap in an LRU if you need one
}
c.cache[email] = entry{d: d, expires: time.Now().Add(c.TTL)}
}
skip_enrichment: true. Signup screening only needs verdict. This flag skips the company enrichment step, so the API answers from lists and heuristics in milliseconds. Drop it when you also want the company profile.
Two timeouts. context.WithTimeout caps each lookup at 1.5 seconds, and because it wraps the request's own context, a client that disconnects cancels the lookup too. The http.Client timeout is a backstop in case someone calls lookup with a context that has no deadline.
The cache key is the full address, not the domain. verdict.is_spam depends on the local part (ccjxx90sze@ is flagged as randomized even on a clean domain), so caching by domain would hand one user's verdict to another. The cache is small and per-process, which is enough to absorb double submits and retries. Caching domain lookups covers longer-lived options. Failures are never cached.
429 sets a backoff instead of retrying. pingutil allows 60 requests per minute per key and returns {"error":"rate_limited","retry_after_seconds":60} when you exceed it. Sleeping for a minute inside a signup request is not an option, so the client records backoffUntil and fails open until then.
400 is a real answer. The API returns invalid_input for inputs it can't normalize. That is a statement about the address, not an outage, so it rejects.
Wiring it into a handler
// cmd/server/main.go
package main
import (
"encoding/json"
"log"
"log/slog"
"net/http"
"os"
"example.com/signup/emailcheck"
)
func main() {
checker := emailcheck.New(os.Getenv("PINGUTIL_KEY"))
checker.Allowlist["yourcompany.com"] = true
http.HandleFunc("POST /signup", func(w http.ResponseWriter, r *http.Request) {
var in struct {
Email string `json:"email"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&in); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
d := checker.Check(r.Context(), in.Email)
if !d.Allow {
msg := "This email address can't be used to sign up."
switch d.Reason {
case "invalid_email":
msg = "Please enter a valid email address."
case "disposable_domain":
msg = "Temporary email addresses aren't allowed."
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnprocessableEntity)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
return
}
if !d.Checked {
slog.Warn("signup accepted unchecked", "reason", d.Reason)
}
// createUser(in.Email) ...
w.WriteHeader(http.StatusCreated)
})
log.Fatal(http.ListenAndServe(":8080", nil))
}
Set PINGUTIL_KEY in the environment of the server process. The key lives only on the server; the browser posts the email to your endpoint, never to the API directly.
Only invalid and disposable addresses get specific messages, because those are the cases a real user can fix. Telling a bot which heuristic it tripped helps nobody but the bot.
Fail open, and know when you did
Every path that couldn't consult the API returns Allow: true, Checked: false with a reason (timeout, rate_limited, unavailable). The handler logs it. In a real app you'd also store the flag on the user row and re-check those accounts with a background job, before they get anything expensive like trial credits or outbound email quota.
Failing closed is defensible when a signup immediately grants money or credits. For everything else, a brief outage that blocks all signups costs more than a handful of disposable addresses getting through. Block or flag disposable emails goes through that trade-off in more detail.
To test without the network, give Client.HTTP a Transport that serves canned responses from an httptest handler, and assert that a 429 puts the client into backoff.
Limits to know about
- The disposable list behind the API is community-maintained and re-synced weekly. A domain registered yesterday won't be on it.
- The randomness check can flag privacy-relay aliases (Apple Hide My Email, Firefox Relay) and some real non-English names. Watch your rejection logs for the first couple of weeks.
- 60 lookups per minute per key, and the cache is per process. If you run many replicas and your signup rate is near that limit, you'll hit the backoff path more than you'd like.
- Nothing here proves the mailbox exists. A confirmation email is still the only reliable check for that.
To see which disposable domains the API flags, browse the category page.
Questions people ask
- Does net/mail.ParseAddress validate an email address in Go?
- It checks RFC 5322 syntax, which is looser than what most signup forms want. It accepts display-name forms like "Jane <jane@acme.com>" and says nothing about whether the domain is real, disposable or a free webmail provider. Use it as the first filter, not the only one.
- Why does publicsuffix.EffectiveTLDPlusOne return foo.github.io instead of github.io?
- The list compiled into golang.org/x/net/publicsuffix includes the private section of the Public Suffix List, where github.io is a suffix. If you want the ICANN-only registrable domain, walk past private rules yourself, as the helper in this post does.
- Should a Go signup handler block when the email check API is down?
- For most products, no. Allow the signup, log that it went unchecked, and re-check later. Blocking every signup during an outage costs more than the few disposable addresses that slip through.