Skip to content

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 →