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 →