Skip to content

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 →