Skip to content

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

// Email Header Analyzer — RFC 5322 header parser + spoofing / authentication
// analysis. Pure string parsing — no dependencies, deterministic.
//
// Language: Java (17+, standard library only)
// Ported from src/lib/email-header-analyzer.ts
// display source — part of CosmoDev's polyglot tool pages.
//
// Timestamps are Instants in UTC; hop delays are nullable Doubles (seconds).

import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public final class EmailHeaderAnalyzer {

    public record Header(String name, String value) {
    }

    public record Hop(String from, String by, Instant timestamp,
                      /** Seconds elapsed since the previous (earlier) hop; null when unknown. */
                      Double delay, String protocol) {
    }

    public record AuthResult(
        /** pass | fail | softfail | neutral | none | temperror | permerror | unknown */
        String result, String domain, String selector,
        /** DMARC policy from the p= token (dmarc only). */
        String policy,
        /** The full authentication clause, when present. */
        String detail) {
    }

    public record AuthResults(AuthResult spf, AuthResult dkim, AuthResult dmarc) {
    }

    public enum AnomalySeverity { CRITICAL, WARNING, INFO }

    public record Anomaly(String type, AnomalySeverity severity, String message) {
    }

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

    private static AuthResult emptyAuth() {
        return new AuthResult("none", null, null, null, null);
    }

    private static final List<String> SUSPICIOUS_MAILERS = List.of(
        "bulk", "mass mail", "massmail", "storm", "flood", "bomber",
        "grabber", "harvest", "spambot", "stealth", "anonymous", "dark", "crack");

    /** Split raw header text into unfolded name/value pairs. Stops at the first
     *  empty line (body separator). */
    public static List<Header> parseHeaderLines(String raw) {
        List<Header> headers = new ArrayList<>();
        Header current = null;
        for (String line : raw.replace("\r\n", "\n").split("\n", -1)) {
            if (line.trim().isEmpty()) break; // end of headers / blank separator
            if (line.matches("^[ \\t].*") && current != null) {
                // Continuation line (RFC 5322 folding) — append to the previous value.
                current = new Header(current.name(), current.value() + " " + line.trim());
                headers.set(headers.size() - 1, current);
                continue;
            }
            int colon = line.indexOf(':');
            if (colon <= 0) continue; // not a header line — skip garbage
            current = new 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. */
    public static List<String> getHeaders(List<Header> headers, String name) {
        String lower = name.toLowerCase(Locale.ROOT);
        List<String> out = new ArrayList<>();
        for (Header h : headers) {
            if (h.name().toLowerCase(Locale.ROOT).equals(lower)) out.add(h.value());
        }
        return out;
    }

    private static final Pattern BRACKET_ADDR = Pattern.compile("<([^<>\\s]+)>");
    private static final Pattern BARE_ADDR = Pattern.compile("[^\\s<>,;\"']+@[^\\s<>,;\"']+");

    /** Extract an email address from a header value: prefers <addr>, falls back
     *  to the first bare address. */
    public static String extractAddress(String value) {
        if (value == null) return null;
        Matcher bracket = BRACKET_ADDR.matcher(value);
        if (bracket.find()) return bracket.group(1);
        Matcher bare = BARE_ADDR.matcher(value);
        return bare.find() ? bare.group() : null;
    }

    private static final Pattern ZONE_COMMENT = Pattern.compile("\\([^)]*\\)");
    private static final Pattern RFC2822_DATE = Pattern.compile(
        "^(?:(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun),\\s*)?" +
        "(\\d{1,2})\\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\\s+(\\d{2,4})\\s+" +
        "(\\d{2}):(\\d{2})(?::(\\d{2}))?\\s*([+-]\\d{4}|[A-Za-z]+)?.*$",
        Pattern.CASE_INSENSITIVE);
    private static final List<String> MONTHS = List.of(
        "JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC");

    /** Parse an RFC 2822 date, ignoring trailing "(ZONE)" comments. Returns
     *  null when unparseable. Named zones map to their modern UTC offsets. */
    public static Instant parseRfc2822Date(String value) {
        String cleaned = ZONE_COMMENT.matcher(value).replaceAll(" ").trim();
        if (cleaned.isEmpty()) return null;
        Matcher m = RFC2822_DATE.matcher(cleaned);
        if (!m.matches()) return null;
        int day = Integer.parseInt(m.group(1));
        int month = MONTHS.indexOf(m.group(2).toUpperCase(Locale.ROOT)) + 1;
        if (month == 0) return null;
        int year = Integer.parseInt(m.group(3));
        if (year < 100) year += year >= 50 ? 1900 : 2000; // two-digit years, RFC 5322 §4.3
        int hour = Integer.parseInt(m.group(4));
        int minute = Integer.parseInt(m.group(5));
        int second = m.group(6) != null ? Integer.parseInt(m.group(6)) : 0;
        ZoneOffset offset = parseZone(m.group(7));
        if (offset == null) return null;
        try {
            return LocalDateTime.of(year, month, day, hour, minute, second).toInstant(offset);
        } catch (java.time.DateTimeException e) {
            return null;
        }
    }

    private static ZoneOffset parseZone(String zone) {
        if (zone == null) return ZoneOffset.UTC; // absent zone — treat as UTC (lenient)
        if (zone.matches("^[+-]\\d{4}$")) {
            int sign = zone.charAt(0) == '-' ? -1 : 1;
            int h = Integer.parseInt(zone.substring(1, 3));
            int min = Integer.parseInt(zone.substring(3, 5));
            return ZoneOffset.ofTotalSeconds(sign * (h * 3600 + min * 60));
        }
        return switch (zone.toUpperCase(Locale.ROOT)) {
            case "GMT", "UT", "UTC", "Z" -> ZoneOffset.UTC;
            case "EST" -> ZoneOffset.ofHours(-5);
            case "EDT" -> ZoneOffset.ofHours(-4);
            case "CST" -> ZoneOffset.ofHours(-6);
            case "CDT" -> ZoneOffset.ofHours(-5);
            case "MST" -> ZoneOffset.ofHours(-7);
            case "MDT" -> ZoneOffset.ofHours(-6);
            case "PST" -> ZoneOffset.ofHours(-8);
            case "PDT" -> ZoneOffset.ofHours(-7);
            default -> null;
        };
    }

    private static final Pattern FROM_HOP = Pattern.compile("(?i)\\bfrom\\s+([^\\s(;]+)");
    private static final Pattern BY_HOP = Pattern.compile("(?i)\\bby\\s+([^\\s(;]+)");
    private static final Pattern WITH_HOP = Pattern.compile("(?i)\\bwith\\s+([^\\s;()]+)");
    private static final Pattern TRAILING_PUNCT = Pattern.compile("[.,]$");
    private static final Pattern DATE_GUESS = Pattern.compile(
        "\\b(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun),\\s+[^\\n]+");

    /** Parse one Received header value into a hop (delay filled in later). */
    public static Hop parseReceived(String value) {
        Matcher from = FROM_HOP.matcher(value);
        Matcher by = BY_HOP.matcher(value);
        Matcher protocol = WITH_HOP.matcher(value);

        // The timestamp follows the last ';' in the value.
        Instant timestamp = null;
        int 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.
            Matcher dateGuess = DATE_GUESS.matcher(value);
            if (dateGuess.find()) timestamp = parseRfc2822Date(dateGuess.group());
        }

        return new Hop(
            from.find() ? TRAILING_PUNCT.matcher(from.group(1)).replaceAll("") : "",
            by.find() ? TRAILING_PUNCT.matcher(by.group(1)).replaceAll("") : "",
            timestamp,
            null,
            protocol.find() ? protocol.group(1) : "");
    }

    private static final Pattern RESULT_WORD = Pattern.compile("(?i)=\\s*([a-z]+)\\b");

    private static String resultWord(String clause) {
        Matcher m = RESULT_WORD.matcher(clause);
        return m.find() ? m.group(1).toLowerCase(Locale.ROOT) : "unknown";
    }

    private static String clauseToken(String clause, String key) {
        Matcher m = Pattern.compile("(?i)\\b" + key + "=([^\\s;)]+)").matcher(clause);
        return m.find() ? TRAILING_PUNCT.matcher(m.group(1)).replaceAll("") : null;
    }

    /**
     * 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
     */
    public static AuthResults parseAuthResults(List<String> values) {
        AuthResult spf = emptyAuth();
        AuthResult dkim = emptyAuth();
        AuthResult dmarc = emptyAuth();
        if (values.isEmpty()) return new AuthResults(spf, dkim, dmarc);

        for (String value : values) {
            for (String rawClause : value.split(";")) {
                String clause = rawClause.trim();
                String lower = clause.toLowerCase(Locale.ROOT);
                final String kind = lower.startsWith("spf=") ? "spf"
                    : lower.startsWith("dkim=") ? "dkim"
                    : lower.startsWith("dmarc=") ? "dmarc" : null;
                if (kind == null) continue;

                AuthResult target = switch (kind) {
                    case "spf" -> spf;
                    case "dkim" -> dkim;
                    default -> dmarc;
                };
                if (!target.result().equals("none")) continue; // first result wins

                AuthResult updated = new AuthResult(resultWord(clause), target.domain(),
                    target.selector(), target.policy(), clause);
                switch (kind) {
                    case "spf" -> {
                        String domain = clauseToken(clause, "smtp.mailfrom");
                        if (domain == null) domain = clauseToken(clause, "mailfrom");
                        spf = new AuthResult(updated.result(), domain, null, null, clause);
                    }
                    case "dkim" -> {
                        String domain = clauseToken(clause, "header.d");
                        if (domain == null) domain = clauseToken(clause, "header.i");
                        dkim = new AuthResult(updated.result(), domain,
                            clauseToken(clause, "header.s"), null, clause);
                    }
                    default -> {
                        Matcher policy = Pattern.compile("(?i)\\bp=([a-z]+)").matcher(clause);
                        dmarc = new AuthResult(updated.result(), clauseToken(clause, "header.from"),
                            null, policy.find() ? policy.group(1).toLowerCase(Locale.ROOT) : null,
                            clause);
                    }
                }
            }
        }
        return new AuthResults(spf, dkim, dmarc);
    }

    private static List<Anomaly> detectAnomalies(List<Header> headers, List<Hop> hops, AuthResults auth) {
        List<Anomaly> anomalies = new ArrayList<>();

        // 1. From vs Return-Path mismatch — classic spoofing signal.
        List<String> fromValues = getHeaders(headers, "From");
        String fromAddr = extractAddress(fromValues.isEmpty() ? "" : fromValues.get(0));
        List<String> rpValues = getHeaders(headers, "Return-Path");
        String returnPath = extractAddress(rpValues.isEmpty() ? "" : rpValues.get(0));
        if (fromAddr != null && returnPath != null
                && !fromAddr.toLowerCase(Locale.ROOT).equals(returnPath.toLowerCase(Locale.ROOT))) {
            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.
        List<String> rtValues = getHeaders(headers, "Reply-To");
        String replyTo = extractAddress(rtValues.isEmpty() ? "" : rtValues.get(0));
        if (fromAddr != null && replyTo != null
                && !replyTo.toLowerCase(Locale.ROOT).equals(fromAddr.toLowerCase(Locale.ROOT))) {
            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.
        List<String> xMailer = getHeaders(headers, "X-Mailer");
        List<String> userAgent = getHeaders(headers, "User-Agent");
        String mailer = !xMailer.isEmpty() ? xMailer.get(0)
            : !userAgent.isEmpty() ? userAgent.get(0) : "";
        if (!mailer.isEmpty()) {
            String lower = mailer.toLowerCase(Locale.ROOT);
            String hit = SUSPICIOUS_MAILERS.stream().filter(lower::contains).findFirst().orElse(null);
            if (hit != 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.size(); i++) {
            Hop hop = hops.get(i);
            if (hop.timestamp() == null) {
                anomalies.add(new Anomaly("hop-missing-timestamp", AnomalySeverity.INFO,
                    "Hop " + (i + 1) + " (" + (!hop.from().isEmpty() ? hop.from()
                        : !hop.by().isEmpty() ? hop.by() : "unknown")
                        + ") has no parseable timestamp — delay for this leg cannot be computed."));
                continue;
            }
            if (i > 0 && hops.get(i - 1).timestamp() != null) {
                double delta = (hop.timestamp().toEpochMilli() - hops.get(i - 1).timestamp().toEpochMilli()) / 1000.0;
                if (delta < 0) {
                    anomalies.add(new Anomaly("negative-delay", AnomalySeverity.WARNING,
                        "Hop " + (i + 1) + " is timestamped " + String.format(Locale.ROOT, "%.1f", 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(new 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.
     */
    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<String> received = getHeaders(headers, "Received");
        List<Hop> reversed = new ArrayList<>();
        for (String value : received) reversed.add(parseReceived(value));
        java.util.Collections.reverse(reversed);
        List<Hop> hops = new ArrayList<>();
        for (int i = 0; i < reversed.size(); i++) {
            Hop hop = reversed.get(i);
            if (i == 0 || hop.timestamp() == null || reversed.get(i - 1).timestamp() == null) {
                hops.add(hop);
                continue;
            }
            double delay = (hop.timestamp().toEpochMilli() - reversed.get(i - 1).timestamp().toEpochMilli()) / 1000.0;
            hops.add(new Hop(hop.from(), hop.by(), hop.timestamp(), delay, hop.protocol()));
        }

        AuthResults auth = parseAuthResults(getHeaders(headers, "Authentication-Results"));
        List<Anomaly> anomalies = detectAnomalies(headers, hops, auth);

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

    private EmailHeaderAnalyzer() {
    }
}

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 →