Skip to content

Email Header Analyzer — Swift source

Paste raw email headers to trace the message path, detect spoofing, and check SPF/DKIM/DMARC authentication results. Runs entirely in your browser.

This is the Swift implementation — the same logic the interactive tool runs, in a shareable, citable form.

// email-header-analyzer — RFC 5322 header parser + spoofing / authentication analysis.
//
// Language: Swift 5.9+ (Foundation only)
// Ported from src/lib/email-header-analyzer.ts
// display source — part of CosmoDev's polyglot tool pages
//
// Pure string parsing — no dependencies, no DOM, deterministic.

import Foundation

// MARK: - Types

struct Header {
    let name: String
    /// `var` because RFC 5322 folded continuation lines append to the previous value.
    var value: String
}

struct Hop {
    let from: String
    let by: String
    let timestamp: Date?
    /// Seconds elapsed since the previous (earlier) hop; nil when unknown.
    var delay: Double?
    /// Backticked: `protocol` is a Swift keyword, but the field name matches the TS reference.
    let `protocol`: String
}

struct AuthResult {
    /// "pass" | "fail" | "softfail" | "neutral" | "none" | "temperror" | "permerror" | "unknown"
    var result: String
    var domain: String?
    var selector: String?
    /// DMARC policy from the `p=` token (dmarc only).
    var policy: String?
    /// The full authentication clause, when present.
    var detail: String?

    /// The "nothing found" value every result starts from.
    static let none = AuthResult(result: "none", domain: nil, selector: nil, policy: nil, detail: nil)
}

struct AuthResults {
    var spf: AuthResult = .none
    var dkim: AuthResult = .none
    var dmarc: AuthResult = .none
}

enum AnomalySeverity: String {
    case critical, warning, info
}

struct Anomaly {
    let type: String
    let severity: AnomalySeverity
    let message: String
}

struct ParsedHeaders {
    let headers: [Header]
    /// Chronological order: hops[0] is the OLDEST hop (parse bottom-up).
    let hops: [Hop]
    let auth: AuthResults
    let anomalies: [Anomaly]
}

private let SUSPICIOUS_MAILERS = [
    "bulk",
    "mass mail",
    "massmail",
    "storm",
    "flood",
    "bomber",
    "grabber",
    "harvest",
    "spambot",
    "stealth",
    "anonymous",
    "dark",
    "crack",
]

// MARK: - Regex helpers
//
// The TS reference leans on inline regex literals. Foundation's
// NSRegularExpression is the closest equivalent; these two helpers keep the
// call sites as readable as the originals.

/// Capture groups of the first match, or nil when the pattern does not match.
/// Index 0 is the whole match; a non-participating group yields nil.
private func matchGroups(
    _ pattern: String,
    _ text: String,
    _ options: NSRegularExpression.Options = [.caseInsensitive]
) -> [String?]? {
    guard let regex = try? NSRegularExpression(pattern: pattern, options: options) else { return nil }
    let ns = text as NSString
    guard let match = regex.firstMatch(in: text, range: NSRange(location: 0, length: ns.length)) else { return nil }
    return (0..<match.numberOfRanges).map { index in
        let range = match.range(at: index)
        return range.location == NSNotFound ? nil : ns.substring(with: range)
    }
}

private extension String {
    var trimmed: String { trimmingCharacters(in: .whitespacesAndNewlines) }

    /// `/[.,]$/` — hostnames copied out of Received headers often keep a trailing separator.
    var withoutTrailingPunctuation: String {
        guard let last = self.last, last == "." || last == "," else { return self }
        return String(dropLast())
    }
}

// MARK: - Header lines

/// Split raw header text into unfolded name/value pairs.
/// Stops at the first empty line (body separator).
func parseHeaderLines(_ raw: String) -> [Header] {
    let lines = raw.replacingOccurrences(of: "\r\n", with: "\n").components(separatedBy: "\n")
    var headers: [Header] = []

    for line in lines {
        if line.trimmed.isEmpty { break } // end of headers / blank separator

        if let first = line.first, first == " " || first == "\t", !headers.isEmpty {
            // Continuation line (RFC 5322 folding) — append to the previous value.
            headers[headers.count - 1].value += " " + line.trimmed
            continue
        }

        guard let colon = line.firstIndex(of: ":"), colon != line.startIndex else { continue } // skip garbage
        headers.append(
            Header(
                name: String(line[line.startIndex..<colon]).trimmed,
                value: String(line[line.index(after: colon)...]).trimmed
            )
        )
    }
    return headers
}

/// All values for a header name, case-insensitive, in file order.
func getHeaders(_ headers: [Header], _ name: String) -> [String] {
    let lower = name.lowercased()
    return headers.filter { $0.name.lowercased() == lower }.map(\.value)
}

/// Extract an email address from a header value:
/// prefers `<addr>`, falls back to the first bare address.
func extractAddress(_ value: String) -> String? {
    if let bracketed = matchGroups(#"<([^<>\s]+)>"#, value, [])?[1] { return bracketed }
    return matchGroups(#"[^\s<>,;"']+@[^\s<>,;"']+"#, value, [])?[0]
}

// MARK: - Dates

/// RFC 2822 shapes seen in the wild, plus a bare-ISO fallback.
/// `en_US_POSIX` keeps month/day names stable regardless of the host locale.
private let RFC2822_FORMATS = [
    "EEE, d MMM yyyy HH:mm:ss Z",
    "EEE, d MMM yyyy HH:mm:ss zzz",
    "EEE, d MMM yyyy HH:mm Z",
    "d MMM yyyy HH:mm:ss Z",
    "d MMM yyyy HH:mm:ss zzz",
    "d MMM yyyy HH:mm Z",
    "yyyy-MM-dd'T'HH:mm:ssZ",
]

private let RFC2822_PARSERS: [DateFormatter] = RFC2822_FORMATS.map { format in
    let formatter = DateFormatter()
    formatter.locale = Locale(identifier: "en_US_POSIX")
    formatter.timeZone = TimeZone(secondsFromGMT: 0)
    formatter.dateFormat = format
    return formatter
}

/// Parse an RFC 2822 date, ignoring trailing "(ZONE)" comments.
/// Returns nil when unparseable.
func parseRfc2822Date(_ value: String) -> Date? {
    guard let stripper = try? NSRegularExpression(pattern: #"\([^)]*\)"#) else { return nil }
    let ns = value as NSString
    let cleaned = stripper
        .stringByReplacingMatches(in: value, range: NSRange(location: 0, length: ns.length), withTemplate: " ")
        .trimmed
    if cleaned.isEmpty { return nil }

    for parser in RFC2822_PARSERS {
        if let date = parser.date(from: cleaned) { return date }
    }
    return nil
}

// MARK: - Received hops

/// Parse one Received header value into a hop (delay filled in later).
func parseReceived(_ value: String) -> Hop {
    let from = matchGroups(#"\bfrom\s+([^\s(;]+)"#, value)?[1]
    let by = matchGroups(#"\bby\s+([^\s(;]+)"#, value)?[1]
    let via = matchGroups(#"\bwith\s+([^\s;()]+)"#, value)?[1]

    // The timestamp follows the last ';' in the value.
    var timestamp: Date?
    if let lastSemi = value.lastIndex(of: ";") {
        timestamp = parseRfc2822Date(String(value[value.index(after: lastSemi)...]))
    }
    if timestamp == nil {
        // Some clients omit the ';'. Fall back to the first date-looking token.
        if let guess = matchGroups(#"\b(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun),\s+[^\n]+"#, value, [])?[0] {
            timestamp = parseRfc2822Date(guess)
        }
    }

    return Hop(
        from: (from ?? "").withoutTrailingPunctuation,
        by: (by ?? "").withoutTrailingPunctuation,
        timestamp: timestamp,
        delay: nil,
        protocol: via ?? ""
    )
}

// MARK: - Authentication-Results

private func resultWord(_ clause: String) -> String {
    matchGroups(#"=\s*([a-z]+)\b"#, clause)?[1]?.lowercased() ?? "unknown"
}

private func clauseToken(_ clause: String, _ key: String) -> String? {
    matchGroups(#"\b\#(key)=([^\s;)]+)"#, clause)?[1]?.withoutTrailingPunctuation
}

/// Parse Authentication-Results clauses. Example:
///   mx.google.com; spf=pass smtp.mailfrom=a@b.com; dkim=pass header.s=sel header.d=b.com;
///   dmarc=pass (p=REJECT) header.from=b.com
func parseAuthResults(_ values: [String]) -> AuthResults {
    var auth = AuthResults()
    if values.isEmpty { return auth }

    for value in values {
        for rawClause in value.components(separatedBy: ";") {
            let clause = rawClause.trimmed
            let lower = clause.lowercased()

            if lower.hasPrefix("spf=") {
                guard auth.spf.result == "none" else { continue } // first result wins
                auth.spf.result = resultWord(clause)
                auth.spf.detail = clause
                auth.spf.domain = clauseToken(clause, "smtp.mailfrom") ?? clauseToken(clause, "mailfrom")
            } else if lower.hasPrefix("dkim=") {
                guard auth.dkim.result == "none" else { continue }
                auth.dkim.result = resultWord(clause)
                auth.dkim.detail = clause
                auth.dkim.domain = clauseToken(clause, "header.d") ?? clauseToken(clause, "header.i")
                auth.dkim.selector = clauseToken(clause, "header.s")
            } else if lower.hasPrefix("dmarc=") {
                guard auth.dmarc.result == "none" else { continue }
                auth.dmarc.result = resultWord(clause)
                auth.dmarc.detail = clause
                auth.dmarc.domain = clauseToken(clause, "header.from")
                auth.dmarc.policy = matchGroups(#"\bp=([a-z]+)"#, clause)?[1]?.lowercased()
            }
        }
    }
    return auth
}

// MARK: - Anomalies

private func detectAnomalies(_ headers: [Header], _ hops: [Hop], _ auth: AuthResults) -> [Anomaly] {
    var anomalies: [Anomaly] = []

    // 1. From vs Return-Path mismatch — classic spoofing signal.
    let fromAddr = extractAddress(getHeaders(headers, "From").first ?? "")
    let returnPath = extractAddress(getHeaders(headers, "Return-Path").first ?? "")
    if let fromAddr, let returnPath, fromAddr.lowercased() != returnPath.lowercased() {
        anomalies.append(
            Anomaly(
                type: "from-return-path-mismatch",
                severity: .critical,
                message: "Return-Path (\(returnPath)) does not match From (\(fromAddr)) — the envelope sender differs from the displayed sender. Common in spoofing and mailing-list relay."
            )
        )
    }

    // 2. Reply-To pointing somewhere other than From.
    let replyTo = extractAddress(getHeaders(headers, "Reply-To").first ?? "")
    if let fromAddr, let replyTo, replyTo.lowercased() != fromAddr.lowercased() {
        anomalies.append(
            Anomaly(
                type: "reply-to-mismatch",
                severity: .warning,
                message: "Reply-To (\(replyTo)) differs from From (\(fromAddr)) — replies would go to a different address than the visible sender."
            )
        )
    }

    // 3. Suspicious X-Mailer / User-Agent strings.
    let mailer = getHeaders(headers, "X-Mailer").first ?? getHeaders(headers, "User-Agent").first ?? ""
    if !mailer.isEmpty, let hit = SUSPICIOUS_MAILERS.first(where: { mailer.lowercased().contains($0) }) {
        anomalies.append(
            Anomaly(
                type: "suspicious-mailer",
                severity: .warning,
                message: "Mailer string \"\(mailer)\" contains a suspicious token (\"\(hit)\") often seen in bulk sending tools."
            )
        )
    }

    // 4. Received chain gaps: unparseable/missing timestamps and time going backwards.
    for (i, hop) in hops.enumerated() {
        guard let timestamp = hop.timestamp else {
            let label = !hop.from.isEmpty ? hop.from : (!hop.by.isEmpty ? hop.by : "unknown")
            anomalies.append(
                Anomaly(
                    type: "hop-missing-timestamp",
                    severity: .info,
                    message: "Hop \(i + 1) (\(label)) has no parseable timestamp — delay for this leg cannot be computed."
                )
            )
            continue
        }
        if i > 0, let previous = hops[i - 1].timestamp {
            let delta = timestamp.timeIntervalSince(previous)
            if delta < 0 {
                anomalies.append(
                    Anomaly(
                        type: "negative-delay",
                        severity: .warning,
                        message: "Hop \(i + 1) is timestamped \(String(format: "%.1f", abs(delta)))s BEFORE hop \(i) — clock skew between servers or a forged Received header."
                    )
                )
            }
        }
    }

    // 5. No authentication results at all.
    if getHeaders(headers, "Authentication-Results").isEmpty {
        anomalies.append(
            Anomaly(
                type: "no-auth-results",
                severity: .info,
                message: "No Authentication-Results header found — SPF/DKIM/DMARC status cannot be verified from this message."
            )
        )
    }

    return anomalies
}

// MARK: - Entry point

/// Parse raw email headers (RFC 5322) into structured data:
/// unfolded headers, chronological Received hops with delays,
/// SPF/DKIM/DMARC results, and spoofing anomalies.
func parseHeaders(_ raw: String) -> ParsedHeaders {
    let headers = parseHeaderLines(raw)

    // Received headers are listed newest-first; parse bottom-up so hops[0] is the oldest.
    var hops = getHeaders(headers, "Received").map(parseReceived).reversed().map { $0 }
    for i in hops.indices where i > 0 {
        guard let current = hops[i].timestamp, let previous = hops[i - 1].timestamp else { continue }
        hops[i].delay = current.timeIntervalSince(previous)
    }

    let auth = parseAuthResults(getHeaders(headers, "Authentication-Results"))
    let anomalies = detectAnomalies(headers, hops, auth)

    return ParsedHeaders(headers: headers, hops: hops, auth: auth, anomalies: anomalies)
}

// MARK: - Demo

/// Example: a spoofed message whose Return-Path does not match From.
func demo() {
    let raw = """
    Return-Path: <bounce@spam.example>
    Received: from relay.example (relay.example [198.51.100.7]) by mx.google.com \
    with ESMTPS id abc123; Mon, 17 Aug 2026 10:00:05 +0000
    Received: from origin.example (origin.example [203.0.113.9]) by relay.example \
    with SMTP id def456; Mon, 17 Aug 2026 10:00:00 +0000
    Authentication-Results: mx.google.com; spf=fail smtp.mailfrom=bounce@spam.example;
     dkim=pass header.s=sel header.d=bank.example; dmarc=fail (p=REJECT) header.from=bank.example
    From: Support <support@bank.example>
    Subject: Verify your account

    body starts here
    """

    let parsed = parseHeaders(raw)
    print("headers: \(parsed.headers.count)")
    print("hops: \(parsed.hops.count)")
    for (i, hop) in parsed.hops.enumerated() {
        let delay = hop.delay.map { String(format: "%.1fs", $0) } ?? "—"
        print("  \(i + 1). \(hop.from) -> \(hop.by) via \(hop.protocol) (+\(delay))")
    }
    print("spf: \(parsed.auth.spf.result), dkim: \(parsed.auth.dkim.result), dmarc: \(parsed.auth.dmarc.result)")
    for anomaly in parsed.anomalies {
        print("[\(anomaly.severity.rawValue)] \(anomaly.message)")
    }
}

Also available in 8 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 →