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 →