Skip to content

Email Validator — Go source

Validate email addresses one at a time or in bulk. Checks syntax, length limits, local-part and domain rules, plus-addressing, and IP-literal domains - all in your browser.

This is the Go implementation — the same logic the interactive tool runs, in a shareable, citable form.

// Package emailvalidator is the Go twin of CosmoDev's src/lib/email-validator.ts
// (dual source: the web lib is TypeScript, the CLI lib is Go — kept in lock-step).
// Pure + deterministic, never panics. The table-driven tests in
// email-validator_test.go share vectors with src/lib/email-validator.test.ts so
// the two implementations are held to the same contract.
//
// The validator is RFC 5321/5322-inspired: it errs on the side of practical
// deliverability (provider-friendly) while still recognising the legal-but-unusual
// forms (quoted local parts, IP-literal domains). The algorithm mirrors the TS
// lib exactly: trim → length cap → split local/domain (honouring a quoted local
// part) → local-part checks → domain checks → assemble the verdict.
package emailvalidator

import (
	"fmt"
	"regexp"
	"slices"
	"strconv"
	"strings"
)

const (
	localMax  = 64
	domainMax = 253
	totalMax  = 320
)

// EmailResult is the structured verdict returned by ValidateEmail. Optional TS
// fields (local, domain, normalized) are represented as plain strings whose zero
// value ("") means "absent", mirroring the TS undefined. Reasons and Warnings are
// always non-nil slices (empty when there are none), matching the TS arrays.
type EmailResult struct {
	Valid      bool
	Local      string
	Domain     string
	Normalized string
	Reasons    []string
	Warnings   []string
}

var (
	// Characters permitted in an unquoted (atom) local part. Same set as
	// LOCAL_CHARS in src/lib/email-validator.ts (A–Z, a–z, 0–9 and the
	// printable specials .!#$%&'*+/=?^_`{|}~-). The backtick is concatenated via
	// a double-quoted literal because Go raw strings cannot contain one.
	localChars = regexp.MustCompile(`^[A-Za-z0-9.!#$%&'*+/=?^_` + "`" + `{|}~-]+$`)

	ipv6Prefix = regexp.MustCompile(`(?i)^ipv6:`)
	digitsOnly = regexp.MustCompile(`^\d+$`)
	labelChars = regexp.MustCompile(`^[A-Za-z0-9-]+$`)
	tldChars   = regexp.MustCompile(`^[A-Za-z]{2,}$`)
	batchSplit = regexp.MustCompile(`\r?\n`)
)

// splitLocalDomain splits an email into local + domain, honouring a quoted ("…")
// local part. It returns ok=false when the address does not contain exactly one
// '@' separating a (possibly quoted) local part from a domain. Mirrors
// splitLocalDomain() in the TS lib.
func splitLocalDomain(email string) (local, domain string, quoted, ok bool) {
	// Work in runes so the index arithmetic mirrors the TS lib's code-unit walk.
	r := []rune(email)

	if len(r) > 0 && r[0] == '"' {
		// Walk the quoted string; a backslash escapes the next rune.
		i := 1
		for i < len(r) {
			switch r[i] {
			case '\\':
				i += 2
				continue
			case '"':
				goto closed
			}
			i += 1
		}
		return "", "", false, false // unterminated quote
	closed:
		at := i + 1
		if at >= len(r) || r[at] != '@' {
			return "", "", false, false // '@' must follow the closing quote
		}
		if slices.Contains(r[at+1:], '@') {
			return "", "", false, false // stray '@' in domain
		}
		return string(r[:at]), string(r[at+1:]), true, true
	}

	first := -1
	for idx, c := range r {
		if c == '@' {
			first = idx
			break
		}
	}
	if first == -1 {
		return "", "", false, false
	}
	if slices.Contains(r[first+1:], '@') {
		return "", "", false, false // multiple '@'
	}
	return string(r[:first]), string(r[first+1:]), false, true
}

// isIPv4 reports whether s is a dotted-quad with octets 0–255 and no leading
// zeros. Mirrors isIPv4() in the TS lib.
func isIPv4(s string) bool {
	parts := strings.Split(s, ".")
	if len(parts) != 4 {
		return false
	}
	for _, p := range parts {
		if !digitsOnly.MatchString(p) {
			return false
		}
		n, err := strconv.Atoi(p)
		if err != nil {
			return false
		}
		if n < 0 || n > 255 || strconv.Itoa(n) != p {
			return false
		}
	}
	return true
}

// validateDomain pushes domain-level problems into the shared reasons/warnings
// slices. Mirrors validateDomain() in the TS lib.
func validateDomain(domain string, reasons, warnings *[]string) {
	if domain == "" {
		*reasons = append(*reasons, "Domain is empty")
		return
	}
	if len(domain) > domainMax {
		*reasons = append(*reasons, fmt.Sprintf("Domain exceeds %d characters", domainMax))
	}

	// IP-literal domain: [1.2.3.4] or [IPv6:…].
	if strings.HasPrefix(domain, "[") && strings.HasSuffix(domain, "]") {
		inner := domain[1 : len(domain)-1]
		if ipv6Prefix.MatchString(inner) {
			*warnings = append(*warnings, "IPv6 literal domain (uncommon; ensure your provider supports it)")
			return
		}
		if isIPv4(inner) {
			*warnings = append(*warnings, "IP-literal domain (uncommon; ensure your provider supports it)")
			return
		}
		*reasons = append(*reasons, "Invalid IP-literal domain")
		return
	}
	if strings.HasPrefix(domain, "[") || strings.HasSuffix(domain, "]") {
		*reasons = append(*reasons, "Malformed IP-literal domain (unmatched brackets)")
		return
	}

	if !strings.Contains(domain, ".") {
		*reasons = append(*reasons, "Domain must contain at least one dot (e.g. example.com)")
		return
	}

	labels := strings.Split(domain, ".")
	for _, label := range labels {
		if label == "" {
			*reasons = append(*reasons, "Domain contains an empty label (consecutive or trailing dots)")
			continue
		}
		if len(label) > 63 {
			*reasons = append(*reasons, "Domain label exceeds 63 characters")
		}
		if !labelChars.MatchString(label) {
			*reasons = append(*reasons, "Domain label contains invalid characters")
		}
		if strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") {
			*reasons = append(*reasons, "Domain label starts or ends with a hyphen")
		}
	}
	tld := labels[len(labels)-1]
	if !tldChars.MatchString(tld) {
		*reasons = append(*reasons, "Top-level domain must be at least two letters")
	}
}

// ValidateEmail validates a single email address and returns a structured
// verdict; it never panics. It is the Go twin of validateEmail() in
// src/lib/email-validator.ts and must agree with it on every shared vector.
func ValidateEmail(raw string) EmailResult {
	reasons := []string{}
	warnings := []string{}
	email := strings.TrimSpace(raw)

	if email == "" {
		return EmailResult{Valid: false, Reasons: []string{"Email is empty"}, Warnings: warnings}
	}

	if len(email) > totalMax {
		reasons = append(reasons, fmt.Sprintf("Email exceeds maximum length of %d characters", totalMax))
	}

	local, domain, quoted, ok := splitLocalDomain(email)
	if !ok {
		reasons = append(reasons, `Email must contain exactly one "@" separating local part and domain`)
		return EmailResult{Valid: false, Reasons: reasons, Warnings: warnings}
	}

	if quoted {
		if len(local) > localMax {
			reasons = append(reasons, fmt.Sprintf("Local part exceeds %d characters", localMax))
		}
		warnings = append(warnings, "Quoted local part (rarely supported by providers)")
	} else if local == "" {
		reasons = append(reasons, "Local part is empty")
	} else {
		if len(local) > localMax {
			reasons = append(reasons, fmt.Sprintf("Local part exceeds %d characters", localMax))
		}
		if strings.HasPrefix(local, ".") || strings.HasSuffix(local, ".") {
			reasons = append(reasons, "Local part starts or ends with a dot")
		}
		if strings.Contains(local, "..") {
			reasons = append(reasons, "Local part contains consecutive dots")
		}
		if !localChars.MatchString(local) {
			reasons = append(reasons, "Local part contains invalid characters")
		}
	}
	if !quoted && strings.Contains(local, "+") {
		warnings = append(warnings, "Plus-addressing (tag) detected — delivers to the base mailbox")
	}

	validateDomain(domain, &reasons, &warnings)

	res := EmailResult{
		Valid:    len(reasons) == 0,
		Local:    local,
		Domain:   domain,
		Reasons:  reasons,
		Warnings: warnings,
	}
	if local != "" && domain != "" {
		res.Normalized = local + "@" + strings.ToLower(domain)
	}
	return res
}

// ValidateBatch validates many emails (one per line); blank and
// whitespace-only lines are skipped. It is the Go twin of validateBatch() in
// src/lib/email-validator.ts.
func ValidateBatch(input string) []EmailResult {
	if input == "" {
		return []EmailResult{}
	}
	results := []EmailResult{}
	for _, line := range batchSplit.Split(input, -1) {
		trimmed := strings.TrimSpace(line)
		if len(trimmed) == 0 {
			continue
		}
		results = append(results, ValidateEmail(trimmed))
	}
	return results
}

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →