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 →