Skip to content

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 →