Skip to content

Email Validator — Kotlin 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 Kotlin implementation — the same logic the interactive tool runs, in a shareable, citable form.

// email-validator — RFC 5321/5322-inspired email validation.
//
// Language: Kotlin (1.9+, 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 kotlin.text.Regex.

/** The structured verdict returned by [validateEmail]. */
data class EmailResult(
    /** True when no blocking reasons were recorded. */
    val valid: Boolean,
    /** Local part (before '@'); empty string when not parseable. */
    val local: String = "",
    /** Domain part (after '@'); empty string when not parseable. */
    val domain: String = "",
    /** `local@lowercased-domain` when both parts exist, else null. */
    val normalized: String? = null,
    /** Blocking problems (`valid` is true iff this is empty). */
    val reasons: List<String> = emptyList(),
    /** Non-blocking observations (rare forms, plus-tags, ...). */
    val warnings: List<String> = emptyList(),
)

/** RFC-inspired length ceilings: local part, domain, total address. */
const val LOCAL_MAX: Int = 64
const val DOMAIN_MAX: Int = 253
const val TOTAL_MAX: Int = 320

// ASCII whitespace trimmed from input edges: space, tab, LF, CR, VT (0x0B), FF (0x0C).
private val WHITESPACE: Set<Char> = setOf(' ', '\t', '\n', '\r', 11.toChar(), 12.toChar())

/** Internal split result: views into the input address. */
private data class Split(val local: String, val domain: String, val quoted: Boolean)

// ------------------------------------------------------------- predicates ---

private fun isAsciiAlpha(c: Char): Boolean = c in 'A'..'Z' || c in 'a'..'z'
private fun isAsciiDigit(c: Char): Boolean = c in '0'..'9'
private fun isAsciiAlnum(c: Char): Boolean = isAsciiAlpha(c) || isAsciiDigit(c)

/** True when every char of `s` belongs to the RFC-style "atom" character set
 * (ASCII alphanumeric plus the printable specials permitted unquoted). */
private fun isAtomLocal(s: String): Boolean =
    s.isNotEmpty() && s.all { c ->
        isAsciiAlnum(c) || c in ".!#$%&'*+/=?^_`{|}~-"
    }

/** A valid domain label: ASCII letters, digits, and hyphens (non-empty). */
private fun isValidLabel(s: String): Boolean =
    s.isNotEmpty() && s.all { isAsciiAlnum(it) || it == '-' }

/** A valid TLD: two or more ASCII letters. Length equals char count when
 * every char is ASCII alphabetic, so the length check is exact. */
private fun isValidTld(s: String): Boolean =
    s.length >= 2 && s.all { isAsciiAlpha(it) }

/** An all-decimal, non-empty octet string. */
private fun isDecimal(s: String): Boolean =
    s.isNotEmpty() && s.all { isAsciiDigit(it) }

/** True when `s` starts with "ipv6:" (case-insensitive). */
private fun isIpv6Literal(s: String): Boolean =
    s.length >= 5 && s.take(5).lowercase() == "ipv6:"

/** True when `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). */
private fun isIpv4(s: String): Boolean {
    val parts = s.split('.')
    if (parts.size != 4) return false
    for (p in parts) {
        if (p.isEmpty() || p.length > 3 || !isDecimal(p)) return false
        val value = p.toInt()
        if (value > 255) return false
        if (p.length > 1 && p[0] == '0') return false // leading zero ("01")
    }
    return true
}

// ------------------------------------------------------------------ split ---

/** 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. */
private fun splitLocalDomain(email: String): Split? {
    if (email.startsWith("\"")) {
        // Walk the quoted string; a backslash escapes the next char (so `\"`
        // does not terminate the quote).
        var i = 1
        val n = email.length
        while (i < n) {
            val ch = email[i]
            if (ch == '\\') {
                i += 2
                continue
            }
            if (ch == '"') break
            i++
        }
        if (i >= n || email[i] != '"') return null // unterminated quote
        val at = i + 1
        if (at >= n || email[at] != '@') return null // '@' must follow the closing quote
        if (email.indexOf('@', at + 1) != -1) return null // stray '@' in the domain
        return Split(email.substring(0, at), email.substring(at + 1), quoted = true)
    }

    val first = email.indexOf('@')
    if (first == -1) return null
    if (email.indexOf('@', first + 1) != -1) return null // multiple '@'
    return Split(email.substring(0, first), email.substring(first + 1), quoted = false)
}

// ---------------------------------------------------------------- domain ---

/** Appends domain-level problems to `reasons` / `warnings`. */
private fun validateDomain(domain: String, reasons: MutableList<String>, warnings: MutableList<String>) {
    if (domain.isEmpty()) {
        reasons += "Domain is empty"
        return
    }
    if (domain.length > DOMAIN_MAX) {
        reasons += "Domain exceeds $DOMAIN_MAX characters"
    }

    // IP-literal domain: [1.2.3.4] or [IPv6:...].
    if (domain.startsWith("[") && domain.endsWith("]")) {
        val inner = domain.substring(1, domain.length - 1)
        if (isIpv6Literal(inner)) {
            warnings += "IPv6 literal domain (uncommon; ensure your provider supports it)"
            return
        }
        if (isIpv4(inner)) {
            warnings += "IP-literal domain (uncommon; ensure your provider supports it)"
            return
        }
        reasons += "Invalid IP-literal domain"
        return
    }
    if (domain.startsWith("[") || domain.endsWith("]")) {
        reasons += "Malformed IP-literal domain (unmatched brackets)"
        return
    }

    if ('.' !in domain) {
        reasons += "Domain must contain at least one dot (e.g. example.com)"
        return
    }

    val labels = domain.split('.')
    for (label in labels) {
        if (label.isEmpty()) {
            reasons += "Domain contains an empty label (consecutive or trailing dots)"
            continue
        }
        if (label.length > 63) {
            reasons += "Domain label exceeds 63 characters"
        }
        if (!isValidLabel(label)) {
            reasons += "Domain label contains invalid characters"
        }
        if (label.startsWith("-") || label.endsWith("-")) {
            reasons += "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.
    val tld = labels.last()
    if (!isValidTld(tld)) {
        reasons += "Top-level domain must be at least two letters"
    }
}

// ----------------------------------------------------------------- email ---

/** Validates a single email address, returning a structured verdict.
 *
 * Pure and deterministic: every malformed input becomes a non-valid result
 * carrying explanatory reasons. */
fun validateEmail(raw: String): EmailResult {
    val reasons = mutableListOf<String>()
    val warnings = mutableListOf<String>()
    val email = raw.trim { it in WHITESPACE }

    if (email.isEmpty()) {
        return EmailResult(valid = false, reasons = listOf("Email is empty"))
    }

    if (email.length > TOTAL_MAX) {
        reasons += "Email exceeds maximum length of $TOTAL_MAX characters"
    }

    val split = splitLocalDomain(email)
    if (split == null) {
        reasons += "Email must contain exactly one \"@\" separating local part and domain"
        return EmailResult(valid = false, reasons = reasons, warnings = warnings)
    }
    val (local, domain, quoted) = split

    if (quoted) {
        // Quoted local parts are RFC-legal but almost universally rejected by
        // mailbox providers — warn, and only length-check structurally.
        if (local.length > LOCAL_MAX) {
            reasons += "Local part exceeds $LOCAL_MAX characters"
        }
        warnings += "Quoted local part (rarely supported by providers)"
    } else if (local.isEmpty()) {
        reasons += "Local part is empty"
    } else {
        if (local.length > LOCAL_MAX) {
            reasons += "Local part exceeds $LOCAL_MAX characters"
        }
        if (local.startsWith(".") || local.endsWith(".")) {
            reasons += "Local part starts or ends with a dot"
        }
        if (".." in local) {
            reasons += "Local part contains consecutive dots"
        }
        if (!isAtomLocal(local)) {
            reasons += "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 (!quoted && '+' in local) {
        warnings += "Plus-addressing (tag) detected — delivers to the base mailbox"
    }

    validateDomain(domain, reasons, warnings)

    val normalized = if (local.isNotEmpty() && domain.isNotEmpty()) {
        "$local@${domain.lowercase()}"
    } else {
        null
    }
    return EmailResult(reasons.isEmpty(), local, domain, normalized, reasons, warnings)
}

/** Validates many addresses — one per line. Blank or whitespace-only lines
 * are skipped. Line endings may be LF or CRLF (matching the reference's
 * `\r?\n` split). */
fun validateBatch(text: String): List<EmailResult> {
    if (text.isEmpty()) return emptyList()
    return text.split(Regex("\r?\n"))
        .map { it.trim { c -> c in WHITESPACE } }
        .filter { it.isNotEmpty() }
        .map { validateEmail(it) }
}

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 →