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 →