Skip to content

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

// email-validator — RFC 5321/5322-inspired email validation.
//
// Language: Swift (5.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 traps.
//
// Self-contained: the character classes used by the original are implemented
// as small ASCII predicates, avoiding NSRegularExpression.

/// The structured verdict returned by `validateEmail`.
struct EmailResult: Equatable {
    /// True when no blocking reasons were recorded.
    let valid: Bool
    /// Local part (before '@'); empty string when not parseable.
    let local: String
    /// Domain part (after '@'); empty string when not parseable.
    let domain: String
    /// `local@lowercased-domain` when both parts exist, else nil.
    let normalized: String?
    /// Blocking problems (`valid` is true iff this is empty).
    let reasons: [String]
    /// Non-blocking observations (rare forms, plus-tags, ...).
    let warnings: [String]
}

/// RFC-inspired length ceilings: local part, domain, total address.
let localMax = 64
let domainMax = 253
let totalMax = 320

/// ASCII whitespace trimmed from input edges: space, tab, LF, CR, VT (0x0B), FF (0x0C).
private let asciiWhitespace: Set<Character> = [" ", "\t", "\n", "\r", "\u{B}", "\u{C}"]

/// Strips ASCII whitespace from both ends (stdlib-only; the Foundation
/// trimmingCharacters(in:) takes a CharacterSet).
private func trimmed(_ s: Substring) -> Substring {
    var start = s.startIndex
    var end = s.endIndex
    while start < end, asciiWhitespace.contains(s[start]) {
        start = s.index(after: start)
    }
    while end > start, asciiWhitespace.contains(s[s.index(before: end)]) {
        end = s.index(before: end)
    }
    return s[start..<end]
}

/// Internal split result: views into the input address.
private struct Split {
    let local: Substring
    let domain: Substring
    let quoted: Bool
}

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

private func isAsciiAlpha(_ c: Character) -> Bool {
    ("A"..."Z").contains(c) || ("a"..."z").contains(c)
}

private func isAsciiDigit(_ c: Character) -> Bool {
    ("0"..."9").contains(c)
}

private func isAsciiAlnum(_ c: Character) -> Bool {
    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 func isAtomLocal(_ s: Substring) -> Bool {
    if s.isEmpty { return false }
    let specials: Set<Character> = [".", "!", "#", "$", "%", "&", "'", "*", "+",
                                    "/", "=", "?", "^", "_", "`", "{", "|", "}", "~", "-"]
    return s.allSatisfy { isAsciiAlnum($0) || specials.contains($0) }
}

/// A valid domain label: ASCII letters, digits, and hyphens (non-empty).
private func isValidLabel(_ s: Substring) -> Bool {
    !s.isEmpty && s.allSatisfy { isAsciiAlnum($0) || $0 == "-" }
}

/// A valid TLD: two or more ASCII letters. Count equals char count when every
/// char is ASCII alphabetic, so the length check is exact.
private func isValidTld(_ s: Substring) -> Bool {
    s.count >= 2 && s.allSatisfy { isAsciiAlpha($0) }
}

/// An all-decimal, non-empty octet string.
private func isDecimal(_ s: Substring) -> Bool {
    !s.isEmpty && s.allSatisfy { isAsciiDigit($0) }
}

/// True when `s` starts with "ipv6:" (case-insensitive).
private func isIpv6Literal(_ s: Substring) -> Bool {
    s.count >= 5 && s.prefix(5).lowercased() == "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 func isIpv4(_ s: Substring) -> Bool {
    let parts = s.split(separator: ".", omittingEmptySubsequences: false)
    guard parts.count == 4 else { return false }
    for p in parts {
        guard !p.isEmpty, p.count <= 3, isDecimal(p) else { return false }
        let value = Int(p) ?? 0
        if value > 255 { return false }
        if p.count > 1 && p.first == "0" { return false } // leading zero ("01")
    }
    return true
}

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

/// Splits an address into local + domain, honouring a quoted ("...") local
/// part. Returns nil when the address cannot be split into exactly one '@' in
/// the right place.
private func splitLocalDomain(_ email: Substring) -> Split? {
    let chars = Array(email)
    if chars.first == "\"" {
        // Walk the quoted string; a backslash escapes the next char (so `\"`
        // does not terminate the quote).
        var i = 1
        while i < chars.count {
            let ch = chars[i]
            if ch == "\\" { i += 2; continue }
            if ch == "\"" { break }
            i += 1
        }
        guard i < chars.count, chars[i] == "\"" else { return nil } // unterminated quote
        let at = i + 1
        guard at < chars.count, chars[at] == "@" else { return nil } // '@' must follow quote
        if chars[(at + 1)...].contains("@") { return nil } // stray '@' in the domain
        let atOffset = email.index(email.startIndex, offsetBy: at)
        return Split(local: email[..<atOffset],
                     domain: email[email.index(after: atOffset)...],
                     quoted: true)
    }

    guard let first = email.firstIndex(of: "@") else { return nil }
    if email[email.index(after: first)...].contains("@") { return nil } // multiple '@'
    return Split(local: email[..<first],
                 domain: email[email.index(after: first)...],
                 quoted: false)
}

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

/// Appends domain-level problems to `reasons` / `warnings`.
private func validateDomain(_ domain: Substring,
                            _ reasons: inout [String],
                            _ warnings: inout [String]) {
    if domain.isEmpty {
        reasons.append("Domain is empty")
        return
    }
    if domain.count > domainMax {
        reasons.append("Domain exceeds \(domainMax) characters")
    }

    // IP-literal domain: [1.2.3.4] or [IPv6:...].
    if domain.hasPrefix("[") && domain.hasSuffix("]") {
        let inner = domain.dropFirst().dropLast()
        if isIpv6Literal(inner) {
            warnings.append("IPv6 literal domain (uncommon; ensure your provider supports it)")
            return
        }
        if isIpv4(inner) {
            warnings.append("IP-literal domain (uncommon; ensure your provider supports it)")
            return
        }
        reasons.append("Invalid IP-literal domain")
        return
    }
    if domain.hasPrefix("[") || domain.hasSuffix("]") {
        reasons.append("Malformed IP-literal domain (unmatched brackets)")
        return
    }

    if !domain.contains(".") {
        reasons.append("Domain must contain at least one dot (e.g. example.com)")
        return
    }

    let labels = domain.split(separator: ".", omittingEmptySubsequences: false)
    for label in labels {
        if label.isEmpty {
            reasons.append("Domain contains an empty label (consecutive or trailing dots)")
            continue
        }
        if label.count > 63 {
            reasons.append("Domain label exceeds 63 characters")
        }
        if !isValidLabel(label) {
            reasons.append("Domain label contains invalid characters")
        }
        if label.hasPrefix("-") || label.hasSuffix("-") {
            reasons.append("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.
    let tld = labels[labels.count - 1]
    if !isValidTld(tld) {
        reasons.append("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.
func validateEmail(_ raw: String) -> EmailResult {
    var reasons: [String] = []
    var warnings: [String] = []
    let email = trimmed(Substring(raw))

    if email.isEmpty {
        return EmailResult(valid: false, local: "", domain: "", normalized: nil,
                           reasons: ["Email is empty"], warnings: warnings)
    }

    if email.count > totalMax {
        reasons.append("Email exceeds maximum length of \(totalMax) characters")
    }

    guard let split = splitLocalDomain(email) else {
        reasons.append("Email must contain exactly one \"@\" separating local part and domain")
        return EmailResult(valid: false, local: "", domain: "", normalized: nil,
                           reasons: reasons, warnings: warnings)
    }
    let 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.count > localMax {
            reasons.append("Local part exceeds \(localMax) characters")
        }
        warnings.append("Quoted local part (rarely supported by providers)")
    } else if local.isEmpty {
        reasons.append("Local part is empty")
    } else {
        if local.count > localMax {
            reasons.append("Local part exceeds \(localMax) characters")
        }
        if local.hasPrefix(".") || local.hasSuffix(".") {
            reasons.append("Local part starts or ends with a dot")
        }
        if local.contains("..") {
            reasons.append("Local part contains consecutive dots")
        }
        if !isAtomLocal(local) {
            reasons.append("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.append("Plus-addressing (tag) detected — delivers to the base mailbox")
    }

    validateDomain(domain, &reasons, &warnings)

    let valid = reasons.isEmpty
    let normalized = (!local.isEmpty && !domain.isEmpty)
        ? "\(local)@\(domain.lowercased())" : nil
    return EmailResult(valid: valid, local: String(local), domain: String(domain),
                       normalized: normalized, reasons: reasons, warnings: 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).
func validateBatch(_ text: String) -> [EmailResult] {
    if text.isEmpty { return [] }
    return text.split(omittingEmptySubsequences: false, whereSeparator: { $0 == "\n" })
        .map { $0.hasSuffix("\r") ? $0.dropLast() : $0 }
        .map { trimmed($0) }
        .filter { !$0.isEmpty }
        .map { validateEmail(String($0)) }
}

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 →