Email verification in Go
Go's default http.Client has no timeout at all, and http.DefaultClient is shared across your whole process. Both facts bite the same way: a slow mail server holds a goroutine open until something else gives up.
The client below is constructed once with explicit timeouts and a tuned transport, then passed around. Every call takes a context.Context, so a canceled request actually cancels.
One *http.Client with an explicit Timeout and a transport tuned for connection reuse. Context on every call, and errors wrapped so errors.As works.
Verify one address
You branch on four results: valid, invalid, catch_all and unknown. Role and disposable are boolean flags beside the result, so test them separately. Unknown is the branch most code forgets, and it is never billed.
package zapbounce
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
)
type Client struct {
key string
http *http.Client
}
func New(key string) *Client {
return &Client{
key: key,
http: &http.Client{
Timeout: 15 * time.Second, // the zero value is no timeout at all
Transport: &http.Transport{
MaxIdleConnsPerHost: 20,
IdleConnTimeout: 90 * time.Second,
},
},
}
}
type Result struct {
Email string `json:"email"`
Verdict string `json:"result"`
Reason string `json:"reason"`
SMTPCode string `json:"smtp_code"`
Billed bool `json:"billed"`
}
func (c *Client) Verify(ctx context.Context, email string) (*Result, error) {
body, _ := json.Marshal(map[string]string{"email": email})
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
"https://api.zapbounce.com/v1/verify", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.key)
req.Header.Set("Content-Type", "application/json")
res, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("verify %s: %w", email, err)
}
defer res.Body.Close() // leak this and connections are never reused
if res.StatusCode != http.StatusOK {
return nil, parseAPIError(res)
}
var out Result
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("decode: %w", err)
}
return &out, nil
}Verify a list
Submit a batch rather than looping the single endpoint. We pace probes per receiving mail host, which protects the sending reputation your results depend on.
// Single verifications in parallel, bounded. Unbounded goroutines against one
// host is how you rate-limit yourself and end up with a page of 429s.
func (c *Client) VerifyMany(ctx context.Context, emails []string, workers int) []Outcome {
jobs := make(chan string)
out := make(chan Outcome, len(emails))
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for email := range jobs {
r, err := c.Verify(ctx, email)
out <- Outcome{Email: email, Result: r, Err: err}
}
}()
}
go func() {
defer close(jobs)
for _, e := range emails {
select {
case jobs <- e:
case <-ctx.Done():
return
}
}
}()
wg.Wait()
close(out)
results := make([]Outcome, 0, len(emails))
for o := range out {
results = append(results, o)
}
return results
}
// For a real list, submit a batch instead and let the server pace the probes.
func (c *Client) SubmitBatch(ctx context.Context, name string, emails []string) (string, error) {
var resp struct {
BatchID string `json:"batch_id"`
}
err := c.post(ctx, "/v1/batches", map[string]any{"name": name, "emails": emails}, &resp)
return resp.BatchID, err
}Errors and retries
A 429 and a 5xx are worth retrying. A 402 for credits and a 400 for a malformed address will fail the same way on every attempt, so stop.
type APIError struct {
Status int
Code string `json:"code"`
Message string `json:"message"`
RequestID string `json:"request_id"`
}
func (e *APIError) Error() string {
return fmt.Sprintf("zapbounce: %s (%s, request_id=%s)", e.Message, e.Code, e.RequestID)
}
// Retryable reports whether another attempt could succeed. A 402 never will.
func (e *APIError) Retryable() bool {
return e.Status == http.StatusTooManyRequests || e.Status >= 500
}
func (c *Client) VerifyWithRetry(ctx context.Context, email string) (*Result, error) {
var last error
for attempt := 0; attempt < 5; attempt++ {
r, err := c.Verify(ctx, email)
if err == nil {
return r, nil
}
last = err
var apiErr *APIError
if errors.As(err, &apiErr) && !apiErr.Retryable() {
return nil, err // insufficient_credits, malformed_email: stop here
}
backoff := time.Duration(1<<attempt) * 500 * time.Millisecond
select {
case <-time.After(backoff + jitter()):
case <-ctx.Done():
return nil, ctx.Err()
}
}
return nil, fmt.Errorf("gave up after 5 attempts: %w", last)
}Go: common questions
Is there a Go module?
No. The client here is about a hundred lines and has no dependency beyond the standard library.
How many workers should I run?
For single verifications, ten to twenty. Past that you are competing with your own rate limit. For anything list-shaped, use a batch and let the server pace it.
Does context cancellation stop the verification?
It stops your request. A probe already in flight on our side finishes, and if it produced a verdict it is billed.
Run this Go code today
100 free checks a month, no card, credits that never expire, and unknown results that cost nothing.