emailverifier.dev
Posts

Go Email Validation Tutorial With a Gin Signup Handler

| 4 min read | Usama Ejaz
Go Gin signup validation cover with one request line entering an email gate.

A Gin signup handler needs a narrower contract than “validate this email.” It should accept one JSON field, make one authenticated verification request, and return a decision that the rest of the application can act on.

input error       400  invalid_request
confirmed block   422  block
uncertain result  202  review
accepted result   200  allow
dependency error  503  retry

The implementation below puts the network call behind a Go interface. Production uses an http.Client with a four-second timeout; tests use a small fake and spend no credits.

Create the Gin module

mkdir gin-email-signup
cd gin-email-signup
go mod init example.com/gin-email-signup
go get github.com/gin-gonic/gin

Gin’s current binding documentation supports JSON tags and validator tags on request structs. This example uses ShouldBindJSON so the handler retains control of the response body.

Add the complete handler

Create main.go:

package main

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"net/http"
	"os"
	"time"

	"github.com/gin-gonic/gin"
)

const verifyURL = "https://emailverifier.dev/api/v1/verify"

type SignupRequest struct {
	Email string `json:"email" binding:"required,email,max=254"`
}

type VerificationResult struct {
	Email      string   `json:"email"`
	Status     string   `json:"status"`
	Action     string   `json:"action"`
	Flagged    bool     `json:"flagged"`
	Signals    []string `json:"signals"`
	Suggestion *string  `json:"suggestion"`
}

type SignupResponse struct {
	Decision   string   `json:"decision"`
	Message    string   `json:"message"`
	Signals    []string `json:"signals,omitempty"`
	Suggestion *string  `json:"suggestion,omitempty"`
}

type Verifier interface {
	Verify(context.Context, string) (VerificationResult, error)
}

type HTTPVerifier struct {
	APIKey string
	Client *http.Client
}

func (v HTTPVerifier) Verify(
	ctx context.Context,
	email string,
) (VerificationResult, error) {
	body, err := json.Marshal(map[string]string{"email": email})
	if err != nil {
		return VerificationResult{}, err
	}

	req, err := http.NewRequestWithContext(
		ctx,
		http.MethodPost,
		verifyURL,
		bytes.NewReader(body),
	)
	if err != nil {
		return VerificationResult{}, err
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("X-API-Key", v.APIKey)

	response, err := v.Client.Do(req)
	if err != nil {
		return VerificationResult{}, err
	}
	defer response.Body.Close()

	if response.StatusCode != http.StatusOK {
		return VerificationResult{}, errors.New("verification API rejected request")
	}

	var result VerificationResult
	if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
		return VerificationResult{}, err
	}
	return result, nil
}

func decision(result VerificationResult) (int, SignupResponse) {
	switch result.Action {
	case "block":
		message := "Use a different email address."
		if result.Suggestion != nil {
			message = "Check the spelling of your email address."
		}
		return http.StatusUnprocessableEntity, SignupResponse{
			Decision: result.Action,
			Message: message,
			Signals: result.Signals,
			Suggestion: result.Suggestion,
		}
	case "review":
		return http.StatusAccepted, SignupResponse{
			Decision: result.Action,
			Message: "Confirm this address before receiving full access.",
			Signals: result.Signals,
		}
	case "allow":
		return http.StatusOK, SignupResponse{
			Decision: result.Action,
			Message: "Continue to account creation.",
			Signals: result.Signals,
		}
	default:
		return http.StatusServiceUnavailable, SignupResponse{
			Decision: "retry",
			Message: "Email verification returned an unexpected response.",
		}
	}
}

func buildRouter(verifier Verifier) *gin.Engine {
	router := gin.New()
	router.Use(gin.Logger(), gin.Recovery())

	router.POST("/signup", func(c *gin.Context) {
		var input SignupRequest
		if err := c.ShouldBindJSON(&input); err != nil {
			c.JSON(http.StatusBadRequest, SignupResponse{
				Decision: "invalid_request",
				Message: "Send a valid email address.",
			})
			return
		}

		result, err := verifier.Verify(c.Request.Context(), input.Email)
		if err != nil {
			c.JSON(http.StatusServiceUnavailable, SignupResponse{
				Decision: "retry",
				Message: "Email verification is temporarily unavailable.",
			})
			return
		}

		status, response := decision(result)
		if response.Decision == "allow" {
			// Create the account here, after the verification decision.
		}
		c.JSON(status, response)
	})

	return router
}

func main() {
	apiKey := os.Getenv("EMAILVERIFIER_API_KEY")
	if apiKey == "" {
		panic("EMAILVERIFIER_API_KEY is not set")
	}

	verifier := HTTPVerifier{
		APIKey: apiKey,
		Client: &http.Client{Timeout: 4 * time.Second},
	}
	if err := buildRouter(verifier).Run(":8080"); err != nil {
		panic(err)
	}
}

HTTPVerifier.Verify contains the only outbound request. The handler does not retry automatically, so one submitted signup cannot quietly consume several verification credits.

Test the policy and call count

Create main_test.go:

package main

import (
	"context"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"

	"github.com/gin-gonic/gin"
)

type fakeVerifier struct {
	result VerificationResult
	err    error
	calls  int
}

func (f *fakeVerifier) Verify(
	_ context.Context,
	_ string,
) (VerificationResult, error) {
	f.calls++
	return f.result, f.err
}

func TestDisposableSignupIsBlockedAfterOneCheck(t *testing.T) {
	gin.SetMode(gin.TestMode)
	fake := &fakeVerifier{result: VerificationResult{
		Status: "risky",
		Action: "block",
		Signals: []string{"disposable_address"},
	}}

	request := httptest.NewRequest(
		http.MethodPost,
		"/signup",
		strings.NewReader(`{"email":"person@example.com"}`),
	)
	request.Header.Set("Content-Type", "application/json")
	response := httptest.NewRecorder()

	buildRouter(fake).ServeHTTP(response, request)

	if response.Code != http.StatusUnprocessableEntity {
		t.Fatalf("expected 422, got %d", response.Code)
	}
	if fake.calls != 1 {
		t.Fatalf("expected one verification call, got %d", fake.calls)
	}
}

func TestMalformedEmailUsesNoCheck(t *testing.T) {
	gin.SetMode(gin.TestMode)
	fake := &fakeVerifier{}
	request := httptest.NewRequest(
		http.MethodPost,
		"/signup",
		strings.NewReader(`{"email":"not-an-email"}`),
	)
	request.Header.Set("Content-Type", "application/json")
	response := httptest.NewRecorder()

	buildRouter(fake).ServeHTTP(response, request)

	if response.Code != http.StatusBadRequest || fake.calls != 0 {
		t.Fatalf("expected local 400 and zero calls")
	}
}

Run the suite:

go test ./...

Start the server and submit a signup

Create a project and copy its API key, then run:

export EMAILVERIFIER_API_KEY="your-project-key"
go run .
curl http://localhost:8080/signup \
  -H "Content-Type: application/json" \
  -d '{"email":"person@example.com"}'

The verification API can report deliverable, risky, undeliverable, or unknown. Your handler acts on the separate allow, review, or block recommendation. This prevents a catch-all response or a blocked SMTP probe from being treated as a nonexistent mailbox.

Role addresses and catch-all domains may be legitimate even when they deserve a different onboarding path. The role-address guide and catch-all guide own those policy decisions.

Continue reading