Email Header Analyzer — TypeScript 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 TypeScript implementation — the same logic the interactive tool runs, in a shareable, citable form.
// RFC 5322 email header parser + spoofing / authentication analysis.
// Pure string parsing — no dependencies, no DOM, deterministic.
export interface Header {
name: string;
value: string;
}
export interface Hop {
from: string;
by: string;
timestamp: Date | null;
/** Seconds elapsed since the previous (earlier) hop; null when unknown. */
delay: number | null;
protocol: string;
}
export interface AuthResult {
/** 'pass' | 'fail' | 'softfail' | 'neutral' | 'none' | 'temperror' | 'permerror' | 'unknown' */
result: string;
domain: string | null;
selector: string | null;
/** DMARC policy from the `p=` token (dmarc only). */
policy: string | null;
/** The full authentication clause, when present. */
detail: string | null;
}
export interface AuthResults {
spf: AuthResult;
dkim: AuthResult;
dmarc: AuthResult;
}
export type AnomalySeverity = 'critical' | 'warning' | 'info';
export interface Anomaly {
type: string;
severity: AnomalySeverity;
message: string;
}
export interface ParsedHeaders {
headers: Header[];
/** Chronological order: hops[0] is the OLDEST hop (parse bottom-up). */
hops: Hop[];
auth: AuthResults;
anomalies: Anomaly[];
}
const NO_AUTH: AuthResult = {
result: 'none',
domain: null,
selector: null,
policy: null,
detail: null,
};
const SUSPICIOUS_MAILERS = [
'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). */
export function parseHeaderLines(raw: string): Header[] {
const lines = raw.replace(/\r\n/g, '\n').split('\n');
const headers: Header[] = [];
let current: Header | null = null;
for (const line of lines) {
if (line.trim() === '') break; // end of headers / blank separator
if (/^[ \t]/.test(line) && current) {
// Continuation line (RFC 5322 folding) — append to the previous value.
current.value += ' ' + line.trim();
continue;
}
const colon = line.indexOf(':');
if (colon <= 0) continue; // not a header line — skip garbage
current = { name: line.slice(0, colon).trim(), value: line.slice(colon + 1).trim() };
headers.push(current);
}
return headers;
}
/** All values for a header name, case-insensitive, in file order. */
export function getHeaders(headers: Header[], name: string): string[] {
const lower = name.toLowerCase();
return headers.filter((h) => h.name.toLowerCase() === lower).map((h) => h.value);
}
/** Extract an email address from a header value: prefers <addr>, falls back to the first bare address. */
export function extractAddress(value: string): string | null {
const bracket = value.match(/<([^<>\s]+)>/);
if (bracket) return bracket[1];
const bare = value.match(/[^\s<>,;"']+@[^\s<>,;"']+/);
return bare ? bare[0] : null;
}
/** Parse an RFC 2822 date, ignoring trailing "(ZONE)" comments. Returns null when unparseable. */
export function parseRfc2822Date(value: string): Date | null {
const cleaned = value.replace(/\([^)]*\)/g, ' ').trim();
if (!cleaned) return null;
const ts = Date.parse(cleaned);
return Number.isNaN(ts) ? null : new Date(ts);
}
/** Parse one Received header value into a hop (delay filled in later). */
export function parseReceived(value: string): Hop {
const from = value.match(/\bfrom\s+([^\s(;]+)/i);
const by = value.match(/\bby\s+([^\s(;]+)/i);
const protocol = value.match(/\bwith\s+([^\s;()]+)/i);
// The timestamp follows the last ';' in the value.
let timestamp: Date | null = null;
const lastSemi = value.lastIndexOf(';');
if (lastSemi !== -1) {
timestamp = parseRfc2822Date(value.slice(lastSemi + 1));
}
if (!timestamp) {
// Some clients omit the ';'. Fall back to the first date-looking token.
const dateGuess = value.match(/\b(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun),\s+[^\n]+/);
if (dateGuess) timestamp = parseRfc2822Date(dateGuess[0]);
}
return {
from: from ? from[1].replace(/[.,]$/, '') : '',
by: by ? by[1].replace(/[.,]$/, '') : '',
timestamp,
delay: null,
protocol: protocol ? protocol[1] : '',
};
}
function emptyAuth(): AuthResult {
return { ...NO_AUTH };
}
function resultWord(clause: string): string {
const m = clause.match(/=\s*([a-z]+)\b/i);
return m ? m[1].toLowerCase() : 'unknown';
}
function clauseToken(clause: string, key: string): string | null {
const m = clause.match(new RegExp(`\\b${key}=([^\\s;)]+)`, 'i'));
return m ? m[1].replace(/[.,]$/, '') : 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
*/
export function parseAuthResults(values: string[]): AuthResults {
const auth: AuthResults = { spf: emptyAuth(), dkim: emptyAuth(), dmarc: emptyAuth() };
if (values.length === 0) return auth;
for (const value of values) {
for (const rawClause of value.split(';')) {
const clause = rawClause.trim();
const lower = clause.toLowerCase();
const kind = lower.startsWith('spf=')
? 'spf'
: lower.startsWith('dkim=')
? 'dkim'
: lower.startsWith('dmarc=')
? 'dmarc'
: null;
if (!kind) continue;
const target = auth[kind];
if (target.result !== 'none') continue; // first result wins
target.result = resultWord(clause);
target.detail = clause;
if (kind === 'spf') {
target.domain = clauseToken(clause, 'smtp.mailfrom') ?? clauseToken(clause, 'mailfrom');
} else if (kind === 'dkim') {
target.domain = clauseToken(clause, 'header.d') ?? clauseToken(clause, 'header.i');
target.selector = clauseToken(clause, 'header.s');
} else {
target.domain = clauseToken(clause, 'header.from');
const policy = clause.match(/\bp=([a-z]+)/i);
target.policy = policy ? policy[1].toLowerCase() : null;
}
}
}
return auth;
}
function detectAnomalies(
headers: Header[],
hops: Hop[],
auth: AuthResults
): Anomaly[] {
const anomalies: Anomaly[] = [];
// 1. From vs Return-Path mismatch — classic spoofing signal.
const fromAddr = extractAddress(getHeaders(headers, 'From')[0] ?? '');
const returnPath = extractAddress(getHeaders(headers, 'Return-Path')[0] ?? '');
if (fromAddr && returnPath && fromAddr.toLowerCase() !== returnPath.toLowerCase()) {
anomalies.push({
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.
const replyTo = extractAddress(getHeaders(headers, 'Reply-To')[0] ?? '');
if (fromAddr && replyTo && replyTo.toLowerCase() !== fromAddr.toLowerCase()) {
anomalies.push({
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.
const mailer = getHeaders(headers, 'X-Mailer')[0] ?? getHeaders(headers, 'User-Agent')[0] ?? '';
if (mailer) {
const hit = SUSPICIOUS_MAILERS.find((s) => mailer.toLowerCase().includes(s));
if (hit) {
anomalies.push({
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 (let i = 0; i < hops.length; i++) {
const hop = hops[i];
if (!hop.timestamp) {
anomalies.push({
type: 'hop-missing-timestamp',
severity: 'info',
message: `Hop ${i + 1} (${hop.from || hop.by || 'unknown'}) has no parseable timestamp — delay for this leg cannot be computed.`,
});
continue;
}
if (i > 0 && hops[i - 1].timestamp) {
const delta = (hop.timestamp.getTime() - hops[i - 1].timestamp!.getTime()) / 1000;
if (delta < 0) {
anomalies.push({
type: 'negative-delay',
severity: 'warning',
message: `Hop ${i + 1} is timestamped ${Math.abs(delta).toFixed(1)}s BEFORE hop ${i} — clock skew between servers or a forged Received header.`,
});
}
}
}
// 5. No authentication results at all.
if (getHeaders(headers, 'Authentication-Results').length === 0) {
anomalies.push({
type: 'no-auth-results',
severity: 'info',
message: '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.
*/
export function parseHeaders(raw: string): ParsedHeaders {
const headers = parseHeaderLines(raw);
// Received headers are listed newest-first; parse bottom-up so hops[0] is the oldest.
const received = getHeaders(headers, 'Received');
const hops = received
.map(parseReceived)
.reverse()
.map((hop, i, arr) => {
if (i === 0 || !hop.timestamp || !arr[i - 1].timestamp) return hop;
return {
...hop,
delay: (hop.timestamp.getTime() - arr[i - 1].timestamp!.getTime()) / 1000,
};
});
const auth = parseAuthResults(getHeaders(headers, 'Authentication-Results'));
const anomalies = detectAnomalies(headers, hops, auth);
return { 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 →