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 →