Email Validator — C# 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 C# implementation — the same logic the interactive tool runs, in a shareable, citable form.
// email-validator — RFC 5321/5322-inspired email validation.
//
// Language: C# (C# 12 / .NET 8+, standard library only)
// Source: CosmoDev polyglot showcase port of the Email Validator tool,
// ported from src/lib/email-validator.ts (the canonical TypeScript
// implementation).
// License: display source — part of CosmoDev's polyglot tool pages.
//
// Practical, provider-friendly validation: errs on the side of deliverability
// while still recognising the legal-but-unusual forms (quoted local parts,
// IP-literal domains). Pure and deterministic — every malformed input becomes
// a non-valid verdict carrying explanatory reasons; nothing below throws.
//
// Self-contained: the character classes used by the original are implemented
// as small char predicates, avoiding System.Text.RegularExpressions.
using System;
using System.Collections.Generic;
using System.Linq;
namespace CosmoDev.EmailValidator;
/// <summary>The structured verdict returned by <see cref="EmailValidator.Validate"/>.</summary>
/// <param name="Valid">True when no blocking reasons were recorded.</param>
/// <param name="Local">Part before '@'; empty when not parseable.</param>
/// <param name="Domain">Part after '@'; empty when not parseable.</param>
/// <param name="Normalized">"<c>local@lowercased-domain</c>" when both parts exist, else null.</param>
/// <param name="Reasons">Blocking problems (<paramref name="Valid"/> is true iff this is empty).</param>
/// <param name="Warnings">Non-blocking observations (rare forms, plus-tags).</param>
public sealed record EmailResult(
bool Valid,
string Local = "",
string Domain = "",
string? Normalized = null,
List<string>? Reasons = null,
List<string>? Warnings = null);
public static class EmailValidator
{
/// <summary>RFC-inspired length ceilings: local part, domain, total address.</summary>
public const int LocalMax = 64;
public const int DomainMax = 253;
public const int TotalMax = 320;
private static readonly char[] Whitespace = [' ', '\t', '\n', '\r', '\v', '\f'];
// ------------------------------------------------------------ predicates ---
/// <summary>True when every char of <paramref name="s"/> belongs to the RFC-style
/// "atom" character set (ASCII alphanumeric plus the printable specials
/// permitted unquoted).</summary>
private static bool IsAtomLocal(string s) =>
s.Length > 0 && s.All(IsAtomChar);
private static bool IsAtomChar(char c) =>
char.IsAsciiLetterOrDigit(c) ||
c is '.' or '!' or '#' or '$' or '%' or '&' or '\'' or '*' or '+' or '/' or
'=' or '?' or '^' or '_' or '`' or '{' or '|' or '}' or '~' or '-';
/// <summary>A valid domain label: ASCII letters, digits, and hyphens (non-empty).</summary>
private static bool IsValidLabel(string s) =>
s.Length > 0 && s.All(c => char.IsAsciiLetterOrDigit(c) || c == '-');
/// <summary>A valid TLD: two or more ASCII letters. Length equals char count when
/// every char is ASCII alphabetic, so the length check is exact.</summary>
private static bool IsValidTld(string s) =>
s.Length >= 2 && s.All(char.IsAsciiLetter);
/// <summary>An all-decimal, non-empty octet string.</summary>
private static bool IsDecimal(string s) =>
s.Length > 0 && s.All(char.IsAsciiDigit);
/// <summary>True when <paramref name="s"/> starts with "ipv6:" (case-insensitive).</summary>
private static bool IsIpv6Literal(string s) =>
s.Length >= 5 && s[..5].Equals("ipv6:", StringComparison.OrdinalIgnoreCase);
/// <summary>True when <paramref name="s"/> is a dotted-quad: four octets, each 0–255,
/// with no leading zeros. The 3-digit length cap rejects arbitrarily long
/// digit strings before they can overflow the value parse (equivalent to
/// the reference's overflow-on-cast behaviour).</summary>
private static bool IsIpv4(string s)
{
string[] parts = s.Split('.');
if (parts.Length != 4) return false;
foreach (string p in parts)
{
if (p.Length is 0 or > 3 || !IsDecimal(p)) return false;
int value = int.Parse(p);
if (value > 255) return false;
if (p.Length > 1 && p[0] == '0') return false; // leading zero ("01")
}
return true;
}
// ----------------------------------------------------------------- split ---
/// <summary>Internal split result: views into the input address.</summary>
/// <param name="Local">Part before '@' (includes the quotes when quoted).</param>
/// <param name="Domain">Part after '@'.</param>
/// <param name="Quoted">True when the local part is a quoted string.</param>
private sealed record Split(string Local, string Domain, bool Quoted);
/// <summary>Splits an address into local + domain, honouring a quoted ("...")
/// local part. Returns null when the address cannot be split into exactly
/// one '@' in the right place.</summary>
private static Split? SplitLocalDomain(string email)
{
if (email.StartsWith('"'))
{
// Walk the quoted string; a backslash escapes the next char (so
// `\"` does not terminate the quote).
int i = 1;
while (i < email.Length)
{
char ch = email[i];
if (ch == '\\') { i += 2; continue; }
if (ch == '"') break;
i++;
}
if (i >= email.Length || email[i] != '"') return null; // unterminated quote
int at = i + 1;
if (at >= email.Length || email[at] != '@') return null; // '@' must follow quote
if (email[(at + 1)..].Contains('@')) return null; // stray '@' in the domain
return new Split(email[..at], email[(at + 1)..], Quoted: true);
}
int first = email.IndexOf('@');
if (first == -1) return null;
if (email[(first + 1)..].Contains('@')) return null; // multiple '@'
return new Split(email[..first], email[(first + 1)..], Quoted: false);
}
// ---------------------------------------------------------------- domain ---
/// <summary>Appends domain-level problems to <paramref name="reasons"/> / <paramref name="warnings"/>.</summary>
private static void ValidateDomain(string domain, List<string> reasons, List<string> warnings)
{
if (domain.Length == 0)
{
reasons.Add("Domain is empty");
return;
}
if (domain.Length > DomainMax)
{
reasons.Add($"Domain exceeds {DomainMax} characters");
}
// IP-literal domain: [1.2.3.4] or [IPv6:...].
if (domain.StartsWith('[') && domain.EndsWith(']'))
{
string inner = domain[1..^1];
if (IsIpv6Literal(inner))
{
warnings.Add("IPv6 literal domain (uncommon; ensure your provider supports it)");
return;
}
if (IsIpv4(inner))
{
warnings.Add("IP-literal domain (uncommon; ensure your provider supports it)");
return;
}
reasons.Add("Invalid IP-literal domain");
return;
}
if (domain.StartsWith('[') || domain.EndsWith(']'))
{
reasons.Add("Malformed IP-literal domain (unmatched brackets)");
return;
}
if (!domain.Contains('.'))
{
reasons.Add("Domain must contain at least one dot (e.g. example.com)");
return;
}
string[] labels = domain.Split('.');
foreach (string label in labels)
{
if (label.Length == 0)
{
reasons.Add("Domain contains an empty label (consecutive or trailing dots)");
continue;
}
if (label.Length > 63)
{
reasons.Add("Domain label exceeds 63 characters");
}
if (!IsValidLabel(label))
{
reasons.Add("Domain label contains invalid characters");
}
if (label.StartsWith('-') || label.EndsWith('-'))
{
reasons.Add("Domain label starts or ends with a hyphen");
}
}
// The TLD is the final label; require >=2 ASCII letters so bare
// hostnames and numeric tails are rejected.
string tld = labels[^1];
if (!IsValidTld(tld))
{
reasons.Add("Top-level domain must be at least two letters");
}
}
// ----------------------------------------------------------------- email ---
/// <summary>Validates a single email address, returning a structured verdict.
/// Pure and deterministic: every malformed input becomes a non-valid result
/// carrying explanatory reasons.</summary>
public static EmailResult Validate(string raw)
{
List<string> reasons = [];
List<string> warnings = [];
string email = raw.Trim(Whitespace);
if (email.Length == 0)
{
return new EmailResult(Valid: false, Reasons: ["Email is empty"], Warnings: warnings);
}
if (email.Length > TotalMax)
{
reasons.Add($"Email exceeds maximum length of {TotalMax} characters");
}
Split? split = SplitLocalDomain(email);
if (split is null)
{
reasons.Add("Email must contain exactly one \"@\" separating local part and domain");
return new EmailResult(Valid: false, Reasons: reasons, Warnings: warnings);
}
string local = split.Local, domain = split.Domain;
if (split.Quoted)
{
// Quoted local parts are RFC-legal but almost universally rejected
// by mailbox providers — warn, and only length-check structurally.
if (local.Length > LocalMax)
{
reasons.Add($"Local part exceeds {LocalMax} characters");
}
warnings.Add("Quoted local part (rarely supported by providers)");
}
else if (local.Length == 0)
{
reasons.Add("Local part is empty");
}
else
{
if (local.Length > LocalMax)
{
reasons.Add($"Local part exceeds {LocalMax} characters");
}
if (local.StartsWith('.') || local.EndsWith('.'))
{
reasons.Add("Local part starts or ends with a dot");
}
if (local.Contains(".."))
{
reasons.Add("Local part contains consecutive dots");
}
if (!IsAtomLocal(local))
{
reasons.Add("Local part contains invalid characters");
}
}
// Plus-addressing (`user+tag@`) is valid and delivers to the base
// mailbox, but callers filtering on exact address may want to know.
if (!split.Quoted && local.Contains('+'))
{
warnings.Add("Plus-addressing (tag) detected — delivers to the base mailbox");
}
ValidateDomain(domain, reasons, warnings);
bool valid = reasons.Count == 0;
string? normalized =
local.Length > 0 && domain.Length > 0 ? $"{local}@{domain.ToLowerInvariant()}" : null;
return new EmailResult(valid, local, domain, normalized, reasons, warnings);
}
/// <summary>Validates many addresses — one per line. Blank or whitespace-only
/// lines are skipped. Line endings may be LF or CRLF (matching the
/// reference's <c>\r?\n</c> split).</summary>
public static List<EmailResult> ValidateBatch(string text)
{
List<EmailResult> results = [];
if (text.Length == 0) return results;
foreach (string line in text.Split('\n'))
{
// TrimEnd('\r') first so CRLF lines become plain LF lines; the
// whitespace trim then removes any remaining padding.
string trimmed = line.TrimEnd('\r').Trim(Whitespace);
if (trimmed.Length > 0)
{
results.Add(Validate(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 →