Skip to content

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

# Email Header Analyzer — RFC 5322 header parser plus SPF/DKIM/DMARC and
# spoofing analysis.
#
# Language: Ruby (3.x, standard library only — 'time' for RFC 2822 dates)
# 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 network calls, no DNS lookups, no mail gem. Feed it
# the raw header block and it returns unfolded headers, the Received chain in
# chronological order with per-leg delays, the authentication verdicts a relay
# recorded, and the anomalies worth showing a human.
#
# Two things this tool deliberately does NOT do: it never *verifies* SPF, DKIM
# or DMARC (it only reports what the receiving relay wrote into
# Authentication-Results), and it never trusts a Received header — the chain is
# attacker-controlled below the first trusted hop, which is exactly why the
# negative-delay and missing-timestamp checks exist.

require 'time'

module EmailHeaderAnalyzer
  Header = Struct.new(:name, :value, keyword_init: true)

  # One relay leg. +delay+ is seconds elapsed since the previous (earlier) hop,
  # nil when either timestamp is unknown.
  Hop = Struct.new(:from, :by, :timestamp, :delay, :protocol, keyword_init: true)

  # +result+ is the verdict word: pass / fail / softfail / neutral / none /
  # temperror / permerror / unknown. +policy+ is the DMARC `p=` token only.
  AuthResult = Struct.new(:result, :domain, :selector, :policy, :detail, keyword_init: true)

  AuthResults = Struct.new(:spf, :dkim, :dmarc, keyword_init: true)

  # +severity+ is :critical, :warning or :info.
  Anomaly = Struct.new(:type, :severity, :message, keyword_init: true)

  ParsedHeaders = Struct.new(:headers, :hops, :auth, :anomalies, keyword_init: true)

  # Tokens seen in the X-Mailer / User-Agent strings of bulk sending tools.
  SUSPICIOUS_MAILERS = [
    'bulk', 'mass mail', 'massmail', 'storm', 'flood', 'bomber', 'grabber',
    'harvest', 'spambot', 'stealth', 'anonymous', 'dark', 'crack'
  ].freeze

  ADDRESS_IN_BRACKETS = /<([^<>\s]+)>/
  BARE_ADDRESS        = /[^\s<>,;"']+@[^\s<>,;"']+/
  DATE_LOOKING_LINE   = /\b(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun),\s+[^\n]+/

  class << self
    # --- public API -----------------------------------------------------------

    # Parse a raw RFC 5322 header block into unfolded headers, chronological
    # Received hops with delays, SPF/DKIM/DMARC results and spoofing anomalies.
    def parse_headers(raw)
      headers = parse_header_lines(raw)

      # Received headers are listed newest-first; reverse so hops[0] is oldest.
      hops = get_headers(headers, 'Received').map { |value| parse_received(value) }.reverse
      fill_delays(hops)

      ParsedHeaders.new(
        headers: headers,
        hops: hops,
        auth: parse_auth_results(get_headers(headers, 'Authentication-Results')),
        anomalies: detect_anomalies(headers, hops)
      )
    end

    # Split raw header text into unfolded name/value pairs. Stops at the first
    # empty line (the RFC 5322 body separator).
    def parse_header_lines(raw)
      headers = []
      current = nil

      raw.to_s.gsub("\r\n", "\n").split("\n", -1).each do |line|
        break if line.strip.empty? # end of headers

        # Continuation line (RFC 5322 folding) — append to the previous value.
        if current && line.match?(/\A[ \t]/)
          current.value = "#{current.value} #{line.strip}"
          next
        end

        colon = line.index(':')
        next if colon.nil? || colon.zero? # not a header line — skip garbage

        current = Header.new(name: line[0, colon].strip, value: line[(colon + 1)..].strip)
        headers << current
      end

      headers
    end

    # All values for a header name, case-insensitive, in file order.
    def get_headers(headers, name)
      wanted = name.to_s.downcase
      headers.select { |h| h.name.to_s.downcase == wanted }.map(&:value)
    end

    # Extract an email address from a header value: prefers <addr>, falls back
    # to the first bare address. Returns nil when there is none.
    def extract_address(value)
      bracket = value.to_s.match(ADDRESS_IN_BRACKETS)
      return bracket[1] if bracket

      bare = value.to_s.match(BARE_ADDRESS)
      bare && bare[0]
    end

    # Parse an RFC 2822 date, ignoring trailing "(ZONE)" comments. Returns nil
    # when unparseable.
    def parse_rfc2822_date(value)
      cleaned = value.to_s.gsub(/\([^)]*\)/, ' ').strip
      return nil if cleaned.empty?

      begin
        Time.rfc2822(cleaned)
      rescue ArgumentError
        # Relays emit plenty of near-RFC dates; fall back to the lenient parser.
        begin
          Time.parse(cleaned)
        rescue ArgumentError, RangeError
          nil
        end
      end
    end

    # Parse one Received header value into a hop (delay is filled in later).
    def parse_received(value)
      text = value.to_s
      from     = text[/\bfrom\s+([^\s(;]+)/i, 1]
      by       = text[/\bby\s+([^\s(;]+)/i, 1]
      protocol = text[/\bwith\s+([^\s;()]+)/i, 1]

      Hop.new(
        from: trim_punctuation(from),
        by: trim_punctuation(by),
        timestamp: received_timestamp(text),
        delay: nil,
        protocol: protocol.to_s
      )
    end

    # 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
    def parse_auth_results(values)
      auth = AuthResults.new(spf: empty_auth, dkim: empty_auth, dmarc: empty_auth)

      Array(values).each do |value|
        value.to_s.split(';').each do |raw_clause|
          clause = raw_clause.strip
          kind = clause_kind(clause)
          next if kind.nil?

          target = auth[kind]
          next unless target.result == 'none' # first result wins

          target.result = result_word(clause)
          target.detail = clause
          apply_clause_tokens(kind, clause, target)
        end
      end

      auth
    end

    private

    # --- Received chain -------------------------------------------------------

    # The timestamp follows the last ';' in the value. Some clients omit the
    # ';' — fall back to the first date-looking token.
    def received_timestamp(text)
      last_semi = text.rindex(';')
      timestamp = last_semi && parse_rfc2822_date(text[(last_semi + 1)..])
      return timestamp if timestamp

      guess = text[DATE_LOOKING_LINE]
      guess && parse_rfc2822_date(guess)
    end

    def trim_punctuation(value)
      value.to_s.sub(/[.,]\z/, '')
    end

    # Seconds between each hop and the one before it, where both are known.
    def fill_delays(hops)
      hops.each_with_index do |hop, i|
        next if i.zero?

        previous = hops[i - 1].timestamp
        next if hop.timestamp.nil? || previous.nil?

        hop.delay = hop.timestamp - previous
      end
    end

    # --- authentication results -----------------------------------------------

    def empty_auth
      AuthResult.new(result: 'none', domain: nil, selector: nil, policy: nil, detail: nil)
    end

    def clause_kind(clause)
      lower = clause.downcase
      return :spf if lower.start_with?('spf=')
      return :dkim if lower.start_with?('dkim=')
      return :dmarc if lower.start_with?('dmarc=')

      nil
    end

    def result_word(clause)
      word = clause[/=\s*([a-z]+)\b/i, 1]
      word ? word.downcase : 'unknown'
    end

    def clause_token(clause, key)
      value = clause[/\b#{Regexp.escape(key)}=([^\s;)]+)/i, 1]
      value && trim_punctuation(value)
    end

    def apply_clause_tokens(kind, clause, target)
      case kind
      when :spf
        target.domain = clause_token(clause, 'smtp.mailfrom') || clause_token(clause, 'mailfrom')
      when :dkim
        target.domain = clause_token(clause, 'header.d') || clause_token(clause, 'header.i')
        target.selector = clause_token(clause, 'header.s')
      else
        target.domain = clause_token(clause, 'header.from')
        policy = clause[/\bp=([a-z]+)/i, 1]
        target.policy = policy&.downcase
      end
    end

    # --- anomalies ------------------------------------------------------------

    def detect_anomalies(headers, hops)
      from_addr = extract_address(get_headers(headers, 'From').first)

      [
        envelope_mismatch(headers, from_addr),
        reply_to_mismatch(headers, from_addr),
        suspicious_mailer(headers),
        *chain_gaps(hops),
        missing_auth_results(headers)
      ].compact
    end

    # From vs Return-Path mismatch — the classic spoofing signal.
    def envelope_mismatch(headers, from_addr)
      return_path = extract_address(get_headers(headers, 'Return-Path').first)
      return nil unless from_addr && return_path
      return nil if return_path.downcase == from_addr.downcase

      Anomaly.new(
        type: :from_return_path_mismatch,
        severity: :critical,
        message: "Return-Path (#{return_path}) does not match From (#{from_addr}) — the envelope " \
                 'sender differs from the displayed sender. Common in spoofing and mailing-list relay.'
      )
    end

    # Reply-To pointing somewhere other than From.
    def reply_to_mismatch(headers, from_addr)
      reply_to = extract_address(get_headers(headers, 'Reply-To').first)
      return nil unless from_addr && reply_to
      return nil if reply_to.downcase == from_addr.downcase

      Anomaly.new(
        type: :reply_to_mismatch,
        severity: :warning,
        message: "Reply-To (#{reply_to}) differs from From (#{from_addr}) — replies would go to a " \
                 'different address than the visible sender.'
      )
    end

    def suspicious_mailer(headers)
      mailer = get_headers(headers, 'X-Mailer').first || get_headers(headers, 'User-Agent').first
      return nil if mailer.nil? || mailer.empty?

      hit = SUSPICIOUS_MAILERS.find { |token| mailer.downcase.include?(token) }
      return nil if hit.nil?

      Anomaly.new(
        type: :suspicious_mailer,
        severity: :warning,
        message: "Mailer string \"#{mailer}\" contains a suspicious token (\"#{hit}\") often seen " \
                 'in bulk sending tools.'
      )
    end

    # Received chain gaps: unparseable timestamps, and time running backwards.
    def chain_gaps(hops)
      hops.each_with_index.map do |hop, i|
        if hop.timestamp.nil?
          next Anomaly.new(
            type: :hop_missing_timestamp,
            severity: :info,
            message: "Hop #{i + 1} (#{hop_label(hop)}) has no parseable timestamp — delay for this " \
                     'leg cannot be computed.'
          )
        end

        previous = i.positive? ? hops[i - 1].timestamp : nil
        next nil if previous.nil?

        delta = hop.timestamp - previous
        next nil unless delta.negative?

        Anomaly.new(
          type: :negative_delay,
          severity: :warning,
          message: "Hop #{i + 1} is timestamped #{format('%.1f', delta.abs)}s BEFORE hop #{i} — " \
                   'clock skew between servers or a forged Received header.'
        )
      end
    end

    def hop_label(hop)
      label = hop.from.to_s.empty? ? hop.by.to_s : hop.from.to_s
      label.empty? ? 'unknown' : label
    end

    def missing_auth_results(headers)
      return nil unless get_headers(headers, 'Authentication-Results').empty?

      Anomaly.new(
        type: :no_auth_results,
        severity: :info,
        message: 'No Authentication-Results header found — SPF/DKIM/DMARC status cannot be ' \
                 'verified from this message.'
      )
    end
  end
end

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 →