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 →