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@, s
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"`
}
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
}
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"
}
}
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
}
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{"[email protected]", "[email protected]", "[email protected]"}
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)
}
}
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.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.