Email Header Analyzer — C# 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 C# implementation — the same logic the interactive tool runs, in a shareable, citable form.
// Email Header Analyzer - RFC 5322 email header parser + spoofing /
// authentication analysis.
//
// Language: C# 12 / .NET 8 (standard library only)
// Source: CosmoDev polyglot showcase port of the Email Header Analyzer
// tool, ported from src/lib/email-header-analyzer.ts (the canonical
// TypeScript implementation).
// License: display source - part of CosmoDev's polyglot tool pages.
//
// Pure string parsing - no dependencies, deterministic. One deliberate
// tolerance difference: the TS reference leans on the JS engine's Date.parse
// for RFC 2822 dates; this port uses DateTimeOffset.TryParse with the
// invariant culture (numeric offsets and GMT-style zone names parse; the
// rare named zones some JS engines accept do not, and count as unparseable).
using System.Globalization;
using System.Text.RegularExpressions;
public sealed record Header(string Name, string Value);
/// <summary>One relay hop from the Received chain.</summary>
/// <param name="From">The sending host, when the header names one.</param>
/// <param name="By">The receiving host, when the header names one.</param>
/// <param name="Timestamp">When the hop happened; null when unparseable.</param>
/// <param name="Delay">Seconds elapsed since the previous (earlier) hop; null when unknown.</param>
/// <param name="Protocol">The "with" protocol clause, e.g. "ESMTP".</param>
public sealed record Hop(
string From,
string By,
DateTimeOffset? Timestamp,
double? Delay,
string Protocol);
/// <summary>One SPF / DKIM / DMARC verdict from Authentication-Results.</summary>
/// <param name="Result">pass | fail | softfail | neutral | none | temperror | permerror | unknown.</param>
/// <param name="Domain">The authenticated domain, when the clause names one.</param>
/// <param name="Selector">The DKIM selector (dkim only).</param>
/// <param name="Policy">The DMARC policy from the p= token (dmarc only).</param>
/// <param name="Detail">The full authentication clause, when present.</param>
public sealed record AuthResult(
string Result,
string? Domain,
string? Selector,
string? Policy,
string? Detail);
public sealed record AuthResults(AuthResult Spf, AuthResult Dkim, AuthResult Dmarc);
public enum AnomalySeverity
{
Critical,
Warning,
Info,
}
public sealed record Anomaly(string Type, AnomalySeverity Severity, string Message);
public sealed record ParsedHeaders(
IReadOnlyList<Header> Headers,
IReadOnlyList<Hop> Hops,
AuthResults Auth,
IReadOnlyList<Anomaly> Anomalies);
public static class EmailHeaderAnalyzer
{
private static readonly AuthResult NoAuth = new("none", null, null, null, null);
private static readonly string[] SuspiciousMailers =
{
"bulk", "mass mail", "massmail", "storm", "flood", "bomber",
"grabber", "harvest", "spambot", "stealth", "anonymous", "dark", "crack",
};
private static readonly Regex BracketAddressRe = new("<([^<>\\s]+)>", RegexOptions.Compiled);
private static readonly Regex BareAddressRe = new("[^\\s<>,;\"']+@[^\\s<>,\"']+", RegexOptions.Compiled);
private static readonly Regex CommentRe = new("\\([^)]*\\)", RegexOptions.Compiled);
private static readonly Regex FromHostRe = new("\\bfrom\\s+([^\\s(;]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex ByHostRe = new("\\bby\\s+([^\\s(;]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex WithProtocolRe = new("\\bwith\\s+([^\\s;()]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex DateGuessRe = new("\\b(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun),\\s*[^\\n]+", RegexOptions.Compiled);
private static readonly Regex ResultWordRe = new("=\\s*([a-z]+)\\b", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex PolicyTokenRe = new("\\bp=([a-z]+)", RegexOptions.Compiled | RegexOptions.IgnoreCase);
/// <summary>
/// Split raw header text into unfolded name/value pairs. Stops at the first
/// empty line (body separator).
/// </summary>
public static List<Header> ParseHeaderLines(string raw)
{
string[] lines = raw.Replace("\r\n", "\n").Split('\n');
var headers = new List<Header>();
Header? current = null;
foreach (string line in lines)
{
if (line.Trim().Length == 0) break; // end of headers / blank separator
if (line.Length > 0 && (line[0] is ' ' or '\t') && current is not null)
{
// Continuation line (RFC 5322 folding) - append to the previous value.
current = current with { Value = $"{current.Value} {line.Trim()}" };
continue;
}
int colon = line.IndexOf(':');
if (colon <= 0) continue; // not a header line - skip garbage
current = new Header(line[..colon].Trim(), line[(colon + 1)..].Trim());
headers.Add(current);
}
return headers;
}
/// <summary>All values for a header name, case-insensitive, in file order.</summary>
public static List<string> GetHeaders(IEnumerable<Header> headers, string name)
{
string lower = name.ToLowerInvariant();
return headers.Where(h => h.Name.ToLowerInvariant() == lower).Select(h => h.Value).ToList();
}
/// <summary>
/// Extract an email address from a header value: prefers the bracketed
/// form, falls back to the first bare address.
/// </summary>
public static string? ExtractAddress(string? value)
{
if (string.IsNullOrEmpty(value)) return null;
Match bracket = BracketAddressRe.Match(value);
if (bracket.Success) return bracket.Groups[1].Value;
Match bare = BareAddressRe.Match(value);
return bare.Success ? bare.Value : null;
}
/// <summary>
/// Parse an RFC 2822 date, ignoring trailing "(ZONE)" comments. Returns
/// null when unparseable.
/// </summary>
public static DateTimeOffset? ParseRfc2822Date(string value)
{
string cleaned = CommentRe.Replace(value, " ").Trim();
if (cleaned.Length == 0) return null;
return DateTimeOffset.TryParse(
cleaned,
CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
out DateTimeOffset parsed)
? parsed
: null;
}
/// <summary>Parse one Received header value into a hop (delay filled in later).</summary>
public static Hop ParseReceived(string value)
{
Match from = FromHostRe.Match(value);
Match by = ByHostRe.Match(value);
Match protocol = WithProtocolRe.Match(value);
// The timestamp follows the last ';' in the value.
DateTimeOffset? timestamp = null;
int lastSemi = value.LastIndexOf(';');
if (lastSemi != -1)
{
timestamp = ParseRfc2822Date(value[(lastSemi + 1)..]);
}
if (timestamp is null)
{
// Some clients omit the ';'. Fall back to the first date-looking token.
Match dateGuess = DateGuessRe.Match(value);
if (dateGuess.Success) timestamp = ParseRfc2822Date(dateGuess.Value);
}
return new Hop(
From: from.Success ? TrimTrailingPunctuation(from.Groups[1].Value) : string.Empty,
By: by.Success ? TrimTrailingPunctuation(by.Groups[1].Value) : string.Empty,
Timestamp: timestamp,
Delay: null,
Protocol: protocol.Success ? protocol.Groups[1].Value : string.Empty);
}
private static string TrimTrailingPunctuation(string s) =>
s.EndsWith('.') || s.EndsWith(',') ? s[..^1] : s;
private static AuthResult EmptyAuth() => NoAuth;
private static string ResultWord(string clause)
{
Match m = ResultWordRe.Match(clause);
return m.Success ? m.Groups[1].Value.ToLowerInvariant() : "unknown";
}
private static string? ClauseToken(string clause, string key)
{
Match m = Regex.Match(clause, $"\\b{Regex.Escape(key)}=([^\\s;)]+)", RegexOptions.IgnoreCase);
return m.Success ? TrimTrailingPunctuation(m.Groups[1].Value) : null;
}
/// <summary>
/// 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
/// </summary>
public static AuthResults ParseAuthResults(IReadOnlyList<string> values)
{
var spf = EmptyAuth();
var dkim = EmptyAuth();
var dmarc = EmptyAuth();
if (values.Count == 0) return new AuthResults(spf, dkim, dmarc);
foreach (string value in values)
{
foreach (string rawClause in value.Split(';'))
{
string clause = rawClause.Trim();
string lower = clause.ToLowerInvariant();
AuthResult target;
if (lower.StartsWith("spf=")) target = spf;
else if (lower.StartsWith("dkim=")) target = dkim;
else if (lower.StartsWith("dmarc=")) target = dmarc;
else continue;
if (target.Result != "none") continue; // first result wins
AuthResult filled = target with { Result = ResultWord(clause), Detail = clause };
if (ReferenceEquals(target, spf))
{
spf = filled with { Domain = ClauseToken(clause, "smtp.mailfrom") ?? ClauseToken(clause, "mailfrom") };
}
else if (ReferenceEquals(target, dkim))
{
dkim = filled with
{
Domain = ClauseToken(clause, "header.d") ?? ClauseToken(clause, "header.i"),
Selector = ClauseToken(clause, "header.s"),
};
}
else
{
Match policy = PolicyTokenRe.Match(clause);
dmarc = filled with
{
Domain = ClauseToken(clause, "header.from"),
Policy = policy.Success ? policy.Groups[1].Value.ToLowerInvariant() : null,
};
}
}
}
return new AuthResults(spf, dkim, dmarc);
}
private static List<Anomaly> DetectAnomalies(List<Header> headers, List<Hop> hops, AuthResults auth)
{
var anomalies = new List<Anomaly>();
// 1. From vs Return-Path mismatch - classic spoofing signal.
string? fromAddr = ExtractAddress(GetHeaders(headers, "From").FirstOrDefault());
string? returnPath = ExtractAddress(GetHeaders(headers, "Return-Path").FirstOrDefault());
if (fromAddr is not null && returnPath is not null &&
!fromAddr.Equals(returnPath, StringComparison.OrdinalIgnoreCase))
{
anomalies.Add(new 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.
string? replyTo = ExtractAddress(GetHeaders(headers, "Reply-To").FirstOrDefault());
if (fromAddr is not null && replyTo is not null &&
!replyTo.Equals(fromAddr, StringComparison.OrdinalIgnoreCase))
{
anomalies.Add(new 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.
string mailer = GetHeaders(headers, "X-Mailer").FirstOrDefault()
?? GetHeaders(headers, "User-Agent").FirstOrDefault()
?? string.Empty;
if (mailer.Length > 0)
{
string? hit = SuspiciousMailers.FirstOrDefault(s => mailer.ToLowerInvariant().Contains(s));
if (hit is not null)
{
anomalies.Add(new 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 (int i = 0; i < hops.Count; i++)
{
Hop hop = hops[i];
if (hop.Timestamp is null)
{
anomalies.Add(new Anomaly(
"hop-missing-timestamp",
AnomalySeverity.Info,
$"Hop {i + 1} ({(hop.From.Length > 0 ? hop.From : hop.By.Length > 0 ? hop.By : "unknown")}) has no parseable timestamp - delay for this leg cannot be computed."));
continue;
}
if (i > 0 && hops[i - 1].Timestamp is { } prev)
{
double delta = (hop.Timestamp.Value - prev).TotalSeconds;
if (delta < 0)
{
anomalies.Add(new Anomaly(
"negative-delay",
AnomalySeverity.Warning,
$"Hop {i + 1} is timestamped {Math.Abs(delta).ToString("F1", CultureInfo.InvariantCulture)}s BEFORE hop {i} - clock skew between servers or a forged Received header."));
}
}
}
// 5. No authentication results at all.
if (GetHeaders(headers, "Authentication-Results").Count == 0)
{
anomalies.Add(new Anomaly(
"no-auth-results",
AnomalySeverity.Info,
"No Authentication-Results header found - SPF/DKIM/DMARC status cannot be verified from this message."));
}
return anomalies;
}
/// <summary>
/// Parse raw email headers (RFC 5322) into structured data: unfolded
/// headers, chronological Received hops with delays, SPF/DKIM/DMARC
/// results, and spoofing anomalies.
/// </summary>
public static ParsedHeaders ParseHeaders(string raw)
{
List<Header> headers = ParseHeaderLines(raw);
// Received headers are listed newest-first; parse bottom-up so hops[0]
// is the oldest.
List<Hop> received = GetHeaders(headers, "Received").Select(ParseReceived).ToList();
received.Reverse();
List<Hop> hops = received.Select((hop, i) =>
{
if (i == 0 || hop.Timestamp is null || received[i - 1].Timestamp is not { } prev) return hop;
return hop with { Delay = (hop.Timestamp.Value - prev).TotalSeconds };
}).ToList();
AuthResults auth = ParseAuthResults(GetHeaders(headers, "Authentication-Results"));
List<Anomaly> anomalies = DetectAnomalies(headers, hops, auth);
return new 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 →