DEV Community

Cover image for Detecting Catch-All and Role-Based Addresses Programmatically in Go
Arjun CS
Arjun CS

Posted on Edited on

Detecting Catch-All and Role-Based Addresses Programmatically in Go

Two kinds of email address cause trouble precisely because they look fine. A catch-all domain accepts mail for any address, so a mailbox check can't prove a specific inbox exists. A role-based address (info@, support@, sales@) belongs to a team, not a person, so it rarely engages.

Neither should be a hard reject. Both should be flagged so your application can decide what to do. Here's a small Go program that does that, with bounded concurrency, timeouts, and a three-way decision instead of a yes/no.

The design

Instead of returning valid or invalid, classify every address as one of three outcomes:

  • Accept: looks fine
  • Review: deliverable but uncertain or low-value (catch-all, role-based, high risk)
  • Reject: invalid or disposable

That third state is the important design choice. Catch-all and role-based addresses aren't errors, they're judgment calls, and your product or campaign decides how to treat them.

Step 1: The result type

The real-time validation API returns a JSON object with fields such as valid, disposable, role_based, catch_all, and risk_score. Map them to a struct:

package main

import (
    "context"
    "encoding/json"
    "fmt"
    "net/http"
    "net/url"
    "os"
    "strings"
    "sync"
    "time"
)

type ValidationResult struct {
    Email       string `json:"email"`
    Valid       bool   `json:"valid"`
    SyntaxValid bool   `json:"syntax_valid"`
    SMTPCheck   string `json:"smtp_check"`
    Disposable  bool   `json:"disposable"`
    RoleBased   bool   `json:"role_based"`
    CatchAll    bool   `json:"catch_all"`
    SEGDetected bool   `json:"seg_detected"`
    RiskScore   int    `json:"risk_score"`
    Status      string `json:"status"`
}
Enter fullscreen mode Exit fullscreen mode

I'm using Gamalogic's validation API for this. Confirm the exact field types against the API documentation before relying on them, since a type mismatch will make decoding fail.

Step 2: The API call, with a context

func validate(ctx context.Context, client *http.Client, email, apiKey string) (*ValidationResult, error) {
    q := url.Values{}
    q.Set("email", email)
    q.Set("api_key", apiKey)

    endpoint := "https://api.gamalogic.com/v1/validate?" + q.Encode()
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
    if err != nil {
        return nil, err
    }

    resp, err := client.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        return nil, fmt.Errorf("validation API returned %d", resp.StatusCode)
    }

    var result ValidationResult
    if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
        return nil, err
    }
    return &result, nil
}
Enter fullscreen mode Exit fullscreen mode

Using NewRequestWithContext means a slow request can be cancelled, and reusing one http.Client lets Go reuse connections.

Step 3: Classify

type Decision string

const (
    Accept Decision = "accept"
    Review Decision = "review"
    Reject Decision = "reject"
)

func classify(r *ValidationResult) (Decision, string) {
    switch {
    case !r.Valid || r.Disposable:
        return Reject, "invalid or disposable"
    case r.CatchAll:
        return Review, "catch-all domain, deliverability uncertain"
    case r.RoleBased:
        return Review, "role-based address"
    case r.RiskScore >= 70:
        return Review, "high risk score"
    default:
        return Accept, "ok"
    }
}
Enter fullscreen mode Exit fullscreen mode

The order matters. Hard failures come first, then the uncertain cases. The risk score threshold of 70 is an arbitrary example, so tune it against how the score behaves on your own data.

A catch-all domain is the clearest reason for a Review state. The server accepts mail for any address, so even a clean SMTP check can't confirm the mailbox is real. Sending to these in smaller batches and watching bounces is a sensible approach.

Step 4: A local fallback for role addresses

If the API is unavailable, a small local check can still catch obvious role-based names:

var roleLocalParts = map[string]struct{}{
    "info": {}, "support": {}, "sales": {}, "admin": {}, "contact": {},
    "hello": {}, "help": {}, "billing": {}, "noreply": {}, "no-reply": {},
    "postmaster": {}, "webmaster": {}, "office": {}, "team": {},
}

func isRoleBasedLocal(email string) bool {
    at := strings.LastIndex(email, "@")
    if at <= 0 {
        return false
    }
    local := strings.ToLower(email[:at])
    if i := strings.Index(local, "+"); i >= 0 {
        local = local[:i] // strip +tag
    }
    _, ok := roleLocalParts[local]
    return ok
}
Enter fullscreen mode Exit fullscreen mode

A list like this is only a rough guide. Teams use all sorts of names, so treat the API result as the source of truth and the local list as a fallback. Role-based addresses behave differently from named contacts, which is why they're worth flagging.

Step 5: Run it concurrently, with a limit

func check(client *http.Client, email, apiKey string) string {
    ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
    defer cancel()

    res, err := validate(ctx, client, email, apiKey)
    if err != nil {
        if isRoleBasedLocal(email) {
            return fmt.Sprintf("%-28s review (role-based, API unavailable)", email)
        }
        return fmt.Sprintf("%-28s retry (API error: %v)", email, err)
    }

    decision, reason := classify(res)
    return fmt.Sprintf("%-28s %s (%s)", email, decision, reason)
}

func main() {
    apiKey := os.Getenv("GAMALOGIC_API_KEY")
    emails := []string{"jane@example.com", "info@example.com", "test@mailinator.com"}

    client := &http.Client{Timeout: 5 * time.Second}
    sem := make(chan struct{}, 5) // at most 5 requests in flight
    var wg sync.WaitGroup
    results := make([]string, len(emails))

    for i, e := range emails {
        wg.Add(1)
        go func(i int, e string) {
            defer wg.Done()
            sem <- struct{}{}
            defer func() { <-sem }()
            results[i] = check(client, e, apiKey)
        }(i, e)
    }

    wg.Wait()
    for _, r := range results {
        fmt.Println(r)
    }
}
Enter fullscreen mode Exit fullscreen mode

Each goroutine writes to its own slice index, so there's no shared-write race, and the channel acts as a semaphore to cap concurrency. Failures return "retry" rather than silently accepting an unchecked address, which is safer for a list-cleaning job than for a live signup form.

Things to watch for

  • Rate limits and credits. Each call is a validation, so check your plan's limits and keep the concurrency cap modest.
  • Large lists. For many thousands of addresses, a bulk upload or bulk API is usually better than one request per address.
  • Don't auto-reject Review cases. A small company's info@ may be the real contact. Decide per use case.
  • Retries. Add retry with backoff for transient errors, and avoid hammering the API during an outage.

Where this fits

This program covers the classification logic, but the detection itself (SMTP checks, catch-all identification, risk scoring) is the work the API does for you. Email Validation platforms like Gamalogic, ZeroBounce and Kickbox expose comparable APIs, so the same three-way structure applies if you use any of them.

Top comments (1)

Collapse
 
mp3sss31 profile image
mp3sss •

Nice three-state classification - treating catch-all and role-based addresses as 'review' instead of a hard reject is exactly how deliverability-focused products should behave. The context-cancelled HTTP calls with bounded concurrency also mirror what we do in Electron's main process when fanning out network checks: a semaphore plus per-request timeouts keeps the batch fast and the UI responsive. One thing I'd add is caching the verdict per domain, since catch-all detection is often reusable across many addresses.