Skip to content

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

// Email Header Analyzer — RFC 5322 header parser + spoofing / authentication
// analysis.
//
// Language: Kotlin 1.9+ (JVM), standard library only.
// Ported from src/lib/email-header-analyzer.ts — display source, part of
// CosmoDev's polyglot tool pages. Functionally equivalent to the TS
// reference: same unfolding rules, same clause parsing, same anomaly catalog
// and messages. Pure string parsing — no network, deterministic.
//
// One deliberate divergence: the TS reference leans on Date.parse for RFC 2822
// dates; the JVM has no such lenient parser, so this port defines its own
// formatter. It accepts what real Received headers carry (optional day
// padding, optional seconds, numeric or GMT zone). Rare named zones such as
// "CEST" parse to null — reported as a missing-timestamp anomaly, exactly
// like any other unparseable date.

import java.time.OffsetDateTime
import java.time.format.DateTimeFormatterBuilder
import java.time.temporal.ChronoField
import java.util.Locale

/** One unfolded `name: value` header line. */
data class Header(val name: String, val value: String)

/** One `Received` hop. Lists are chronological: hops[0] is the OLDEST. */
data class Hop(
    val from: String,
    val by: String,
    val timestamp: OffsetDateTime?,
    /** Seconds elapsed since the previous (earlier) hop; null when unknown. */
    val delay: Double?,
    val protocol: String,
)

/** The SPF / DKIM / DMARC verdict carried in Authentication-Results clauses. */
data class AuthResult(
    /** pass | fail | softfail | neutral | none | temperror | permerror | unknown */
    val result: String,
    val domain: String?,
    val selector: String?,
    /** DMARC policy from the `p=` token (dmarc only). */
    val policy: String?,
    /** The full authentication clause, when present. */
    val detail: String?,
)

data class AuthResults(val spf: AuthResult, val dkim: AuthResult, val dmarc: AuthResult)

enum class AnomalySeverity { CRITICAL, WARNING, INFO }

data class Anomaly(val type: String, val severity: AnomalySeverity, val message: String)

data class ParsedHeaders(
    val headers: List<Header>,
    /** Chronological order: hops[0] is the OLDEST hop (parse bottom-up). */
    val hops: List<Hop>,
    val auth: AuthResults,
    val anomalies: List<Anomaly>,
)

private val NO_AUTH = AuthResult("none", null, null, null, null)

private val SUSPICIOUS_MAILERS = listOf(
    "bulk", "mass mail", "massmail", "storm", "flood", "bomber", "grabber",
    "harvest", "spambot", "stealth", "anonymous", "dark", "crack",
)

/** RFC 2822 date pattern: `Tue, 3 Jun 2008 11:05[:40] [+0200|GMT|UTC]`. */
private val RFC2822 = DateTimeFormatterBuilder()
    .parseCaseInsensitive()
    .parseLenient()
    .appendPattern("E, d MMM uuuu HH:mm")
    .optionalStart().appendPattern(":ss").optionalEnd()
    .optionalStart().appendPattern(" zzz").optionalEnd() // GMT / UTC names
    .optionalStart().appendPattern(" X").optionalEnd()   // +0200 / +02 / Z
    .parseDefaulting(ChronoField.SECOND_OF_MINUTE, 0)
    .parseDefaulting(ChronoField.OFFSET_SECONDS, 0)
    .toFormatter(Locale.ROOT)

/**
 * Split raw header text into unfolded name/value pairs. Stops at the first
 * empty line (body separator). Continuation lines (RFC 5322 folding) join the
 * previous value; lines without a colon are skipped.
 */
fun parseHeaderLines(raw: String): List<Header> {
    val headers = mutableListOf<Header>()
    var current: Header? = null

    for (line in raw.replace("\r\n", "\n").split('\n')) {
        if (line.isBlank()) break // end of headers / blank separator
        if ((line.startsWith(" ") || line.startsWith("\t")) && current != null) {
            // Continuation line — append to the previous value.
            current = current.copy(value = current.value + " " + line.trim())
            headers[headers.lastIndex] = current
            continue
        }
        val colon = line.indexOf(':')
        if (colon <= 0) continue // not a header line — skip garbage
        current = Header(line.substring(0, colon).trim(), line.substring(colon + 1).trim())
        headers.add(current)
    }
    return headers
}

/** All values for a header name, case-insensitive, in file order. */
fun getHeaders(headers: List<Header>, name: String): List<String> =
    headers.filter { it.name.equals(name, ignoreCase = true) }.map { it.value }

private val BRACKET_ADDR = Regex("<([^<>\\s]+)>")
private val BARE_ADDR = Regex("[^\\s<>,;\"]+@[^\\s<>,;\"]+")

/**
 * Extract an email address from a header value: prefers `<addr>`, falls back
 * to the first bare address.
 */
fun extractAddress(value: String): String? {
    BRACKET_ADDR.find(value)?.let { return it.groupValues[1] }
    return BARE_ADDR.find(value)?.value
}

/** Parse an RFC 2822 date, ignoring trailing "(ZONE)" comments. Null when unparseable. */
fun parseRfc2822Date(value: String): OffsetDateTime? {
    val cleaned = value.replace(Regex("\\([^)]*\\)"), " ").trim()
    if (cleaned.isEmpty()) return null
    return try {
        OffsetDateTime.parse(cleaned, RFC2822)
    } catch (_: Exception) {
        null
    }
}

private val FROM_RE = Regex("\\bfrom\\s+([^\\s(;]+)", RegexOption.IGNORE_CASE)
private val BY_RE = Regex("\\bby\\s+([^\\s(;]+)", RegexOption.IGNORE_CASE)
private val WITH_RE = Regex("\\bwith\\s+([^\\s;()]+)", RegexOption.IGNORE_CASE)
private val DAY_RE = Regex("\\b(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun),\\s+[^\\n]+")
private val TRAILING_PUNCT = Regex("[.,]$")

/** Parse one Received header value into a hop (delay filled in later). */
fun parseReceived(value: String): Hop {
    val from = FROM_RE.find(value)?.groupValues?.get(1)?.replace(TRAILING_PUNCT, "") ?: ""
    val by = BY_RE.find(value)?.groupValues?.get(1)?.replace(TRAILING_PUNCT, "") ?: ""
    val protocol = WITH_RE.find(value)?.groupValues?.get(1) ?: ""

    // The timestamp follows the last ';' in the value.
    var timestamp: OffsetDateTime? = null
    val lastSemi = value.lastIndexOf(';')
    if (lastSemi != -1) {
        timestamp = parseRfc2822Date(value.substring(lastSemi + 1))
    }
    if (timestamp == null) {
        // Some clients omit the ';'. Fall back to the first date-looking token.
        DAY_RE.find(value)?.let { timestamp = parseRfc2822Date(it.value) }
    }

    return Hop(from, by, timestamp, delay = null, protocol)
}

private fun emptyAuth(): AuthResult = NO_AUTH.copy()

private fun resultWord(clause: String): String =
    Regex("=\\s*([a-z]+)\\b", RegexOption.IGNORE_CASE).find(clause)
        ?.groupValues?.get(1)?.lowercase() ?: "unknown"

private fun clauseToken(clause: String, key: String): String? =
    Regex("\\b${key}=([^\\s;)]+)", RegexOption.IGNORE_CASE).find(clause)
        ?.groupValues?.get(1)?.replace(TRAILING_PUNCT, "")

/**
 * 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
 *
 * The first clause per mechanism wins, matching the TS reference.
 */
fun parseAuthResults(values: List<String>): AuthResults {
    var spf = emptyAuth()
    var dkim = emptyAuth()
    var dmarc = emptyAuth()
    if (values.isEmpty()) return AuthResults(spf, dkim, dmarc)

    for (value in values) {
        clause@ for (rawClause in value.split(';')) {
            val clause = rawClause.trim()
            val lower = clause.lowercase()
            when {
                lower.startsWith("spf=") -> {
                    if (spf.result != "none") continue@clause // first result wins
                    spf = spf.copy(
                        result = resultWord(clause),
                        detail = clause,
                        domain = clauseToken(clause, "smtp.mailfrom")
                            ?: clauseToken(clause, "mailfrom"),
                    )
                }
                lower.startsWith("dkim=") -> {
                    if (dkim.result != "none") continue@clause
                    dkim = dkim.copy(
                        result = resultWord(clause),
                        detail = clause,
                        domain = clauseToken(clause, "header.d")
                            ?: clauseToken(clause, "header.i"),
                        selector = clauseToken(clause, "header.s"),
                    )
                }
                lower.startsWith("dmarc=") -> {
                    if (dmarc.result != "none") continue@clause
                    dmarc = dmarc.copy(
                        result = resultWord(clause),
                        detail = clause,
                        domain = clauseToken(clause, "header.from"),
                        policy = Regex("\\bp=([a-z]+)", RegexOption.IGNORE_CASE).find(clause)
                            ?.groupValues?.get(1)?.lowercase(),
                    )
                }
            }
        }
    }
    return AuthResults(spf, dkim, dmarc)
}

private fun detectAnomalies(headers: List<Header>, hops: List<Hop>): List<Anomaly> {
    val anomalies = mutableListOf<Anomaly>()

    // 1. From vs Return-Path mismatch — classic spoofing signal.
    val fromAddr = extractAddress(getHeaders(headers, "From").firstOrNull() ?: "")
    val returnPath = extractAddress(getHeaders(headers, "Return-Path").firstOrNull() ?: "")
    if (fromAddr != null && returnPath != null && !fromAddr.equals(returnPath, ignoreCase = true)) {
        anomalies.add(
            Anomaly(
                "from-return-path-mismatch", AnomalySeverity.CRITICAL,
                "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.
    val replyTo = extractAddress(getHeaders(headers, "Reply-To").firstOrNull() ?: "")
    if (fromAddr != null && replyTo != null && !replyTo.equals(fromAddr, ignoreCase = true)) {
        anomalies.add(
            Anomaly(
                "reply-to-mismatch", AnomalySeverity.WARNING,
                "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.
    val mailer = getHeaders(headers, "X-Mailer").firstOrNull()
        ?: getHeaders(headers, "User-Agent").firstOrNull() ?: ""
    if (mailer.isNotEmpty()) {
        val hit = SUSPICIOUS_MAILERS.firstOrNull { mailer.lowercase().contains(it) }
        if (hit != null) {
            anomalies.add(
                Anomaly(
                    "suspicious-mailer", AnomalySeverity.WARNING,
                    "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 in hops.indices) {
        val hop = hops[i]
        val ts = hop.timestamp
        if (ts == null) {
            anomalies.add(
                Anomaly(
                    "hop-missing-timestamp", AnomalySeverity.INFO,
                    "Hop ${i + 1} (${hop.from.ifEmpty { hop.by.ifEmpty { "unknown" } }}) has no parseable timestamp — delay for this leg cannot be computed.",
                )
            )
            continue
        }
        val prev = hops.getOrNull(i - 1)?.timestamp
        if (i > 0 && prev != null) {
            val delta = (ts.toEpochSecond() - prev.toEpochSecond()).toDouble()
            if (delta < 0) {
                anomalies.add(
                    Anomaly(
                        "negative-delay", AnomalySeverity.WARNING,
                        "Hop ${i + 1} is timestamped ${"%.1f".format(kotlin.math.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.add(
            Anomaly(
                "no-auth-results", AnomalySeverity.INFO,
                "No Authentication-Results header found — SPF/DKIM/DMARC status cannot be verified from this message.",
            )
        )
    }

    return anomalies
}

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

    // Received headers are listed newest-first; parse bottom-up so hops[0] is
    // the oldest, then fill each hop's delay from its predecessor.
    val received = getHeaders(headers, "Received").map(::parseReceived).reversed()
    val hops = received.mapIndexed { i, hop ->
        val delay = when {
            i == 0 -> null
            hop.timestamp == null || received[i - 1].timestamp == null -> null
            else -> (hop.timestamp!!.toEpochSecond() - received[i - 1].timestamp!!.toEpochSecond()).toDouble()
        }
        hop.copy(delay = delay)
    }

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

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

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 →