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 statuses: valid, invalid, catch_all and unknown. Role and disposable arrive in the flags array beside the status, so test them separately. Unknown is the branch most code forgets, and its credit is refunded.
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"`
Status string `json:"status"` // valid | invalid | catch_all | unknown
ReasonCode string `json:"reasonCode"`
Flags []string `json:"flags"` // role, disposable, free_provider
SMTPCode *int `json:"smtpCode"`
CheckedAt string `json:"checkedAt"`
}
func (c *Client) Verify(ctx context.Context, email string) (*Result, error) {
body, _ := json.Marshal(map[string][]string{"emails": {email}})
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
"https://app.zapbounce.com/api/v1/verify", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
req.Header.Set("X-API-Key", 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 struct {
Results []Result `json:"results"`
}
if err := json.NewDecoder(res.Body).Decode(&out); err != nil || len(out.Results) == 0 {
return nil, fmt.Errorf("decode: %w", err)
}
return &out.Results[0], nil
}Verify a list
Submit a job 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 job instead and let the server pace the probes.
// Up to 50,000 addresses per job. There is no webhook: poll until it finishes.
func (c *Client) SubmitJob(ctx context.Context, emails []string) (string, error) {
var resp struct {
ID string `json:"id"`
}
err := c.post(ctx, "/jobs", map[string]any{"emails": emails, "source": "api"}, &resp)
return resp.ID, err
}
func (c *Client) WaitForJob(ctx context.Context, id string) error {
for {
var job struct {
Status string `json:"status"`
}
if err := c.get(ctx, "/jobs/"+id, &job); err != nil {
return err
}
switch job.Status {
case "completed", "partial", "failed", "cancelled":
return nil
}
select {
case <-time.After(5 * time.Second):
case <-ctx.Done():
return ctx.Err()
}
}
}
// Rows come from GET /jobs/{id}/emails?page=N&pageSize=100 (meta.totalPages tells you when to stop),
// or stream GET /jobs/{id}/export for the whole job as CSV.Errors and retries
A 429 and a 5xx are worth retrying. A 422 CREDITS_INSUFFICIENT 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:"requestId"`
WindowSeconds int // from X-RateLimit-Window-Seconds, set by parseAPIError
}
func (e *APIError) Error() string {
return fmt.Sprintf("zapbounce: %s (%s, requestId=%s)", e.Message, e.Code, e.RequestID)
}
// Retryable reports whether another attempt could succeed. A 422 CREDITS_INSUFFICIENT 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 // CREDITS_INSUFFICIENT, VERIFY_EMPTY: stop here
}
if errors.Is(err, context.DeadlineExceeded) {
// A timeout is not a bad address. Fail open.
return &Result{Email: email, Status: "unknown", ReasonCode: "TIMEOUT"}, nil
}
// No Retry-After header exists: a 429 backs off from the window length.
base := 500 * time.Millisecond
if apiErr != nil && apiErr.Status == http.StatusTooManyRequests {
base = time.Duration(max(apiErr.WindowSeconds, 1)) * time.Second
}
backoff := time.Duration(1<<attempt) * base
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 job 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 other than unknown it spends a credit.
Run this Go code today
100 free checks a month, no card, credits that never expire, and unknown results refunded.