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 →