Analysing email headers with Python: a hands-on CSIRT lab

Every phishing email that lands in someone's inbox leaves a paper trail.

In this lab you will parse a real-looking phishing .eml file using nothing but Python's standard library. By the end you will know which header fields to trust, which to ignore, and how to build a simple triage script that surfaces red flags automatically.

Background: why you can't trust the From field

Email runs on a protocol called SMTP — Simple Mail Transfer Protocol. It was designed in 1982, at a time when the internet was a small academic network and nobody was thinking about abuse at scale. The result: SMTP has no built-in authentication. Anyone can send an email that claims to be from ceo@yourcompany.com. The From field you see in your email client is just a label — the server does not verify it.

This is not a bug that was missed. It is a consequence of design decisions that prioritised simplicity. The fix came decades later in the form of three layered standards: SPF, DKIM, and DMARC. More on those shortly.

How an email actually travels

When you send an email, your client hands it to a mail server (your outgoing SMTP relay). That server passes it along — potentially through several intermediate servers — until it reaches the recipient's mail server, which delivers it to their inbox. Each server that handles the message adds a Received: header, stamping its own identity and the time it handled the message.

This means a legitimate email will have a chain of Received: headers that reads bottom to top — the oldest hop is at the bottom, the most recent is at the top. An attacker forging headers can add fake Received: lines, but they can only prepend them — they cannot inject entries into the middle of the chain without breaking it.

Anatomy of an email header

Open any raw email (in Gmail: the three-dot menu → "Show original") and you will see something like this:

Received: from mail.suspicious-domain.xyz (mail.suspicious-domain.xyz [203.0.113.42])
        by mx.example.com with ESMTP id abc123
        for <victim@example.com>; Mon, 10 Apr 2024 09:15:03 +0100
Received: from localhost (localhost [127.0.0.1])
        by mail.suspicious-domain.xyz with SMTP id xyz789
        Mon, 10 Apr 2024 09:14:58 +0100
DKIM-Signature: v=1; a=rsa-sha256; d=suspicious-domain.xyz; ...
Authentication-Results: mx.example.com;
       spf=fail (sender IP is 203.0.113.42) smtp.mailfrom=ceo@yourcompany.com;
       dkim=fail header.d=yourcompany.com;
       dmarc=fail action=none header.from=yourcompany.com
From: "CEO Name" <ceo@yourcompany.com>
Reply-To: attacker@suspicious-domain.xyz
To: victim@example.com
Subject: Urgent: Wire Transfer Required
Date: Mon, 10 Apr 2024 09:14:55 +0100
Message-ID: <random-string@suspicious-domain.xyz>

The fields that matter most for triage:

Field What it tells you
Received: Every mail server that handled the message. Read bottom to top for the origin.
From: What the sender claims. Not verified.
Reply-To: Where replies actually go. Mismatch with From is a major red flag.
Authentication-Results: Your mail server's verdict on SPF, DKIM, and DMARC.
DKIM-Signature: Cryptographic signature from the sending domain.
Message-ID: Unique ID. The domain after @ should match the sending domain.
X-Originating-IP: Original sender IP — not always present but useful when it is.

The security fields: SPF, DKIM, and DMARC

These three standards exist specifically because SMTP doesn't authenticate senders. They work together but protect different things.

SPF (Sender Policy Framework) — The domain owner publishes a DNS record listing which IP addresses are allowed to send email for that domain. When your mail server receives a message, it checks whether the sending server's IP is on that list. A spf=fail means the email came from an IP not authorised by the domain it claims to be from.

DKIM (DomainKeys Identified Mail) — The sending mail server cryptographically signs the message using a private key. The corresponding public key is published in DNS. Your mail server verifies the signature. A dkim=fail means either the signature is missing, or the message was tampered with in transit.

DMARC (Domain-based Message Authentication, Reporting and Conformance) — Builds on SPF and DKIM. The domain owner publishes a policy that says what to do with messages that fail both checks: none (log only), quarantine (send to spam), or reject (drop it). A dmarc=fail with action=none means the domain has DMARC configured but is not yet enforcing it — the message gets through regardless.

Note: All three of these results appear in the Authentication-Results: header — added by your receiving mail server, not the sender. This header is trustworthy because your own server wrote it. The DKIM-Signature: header comes from the sender and must be cryptographically verified; do not read it as a pass/fail — read Authentication-Results: instead.

The security angle

From a CSIRT perspective, email header analysis sits inside phishing triage — one of the most common ticket types in any security operations team. The MITRE ATT&CK framework catalogues this as T1566 (Phishing) under the Initial Access tactic.

The attacker's goal is to get the victim to act before they think. The technical infrastructure supporting the attack — spoofed domains, lookalike sender addresses, mismatched Reply-To fields — is designed to pass a casual glance. Header analysis is how you get past the glance.

A few things an attacker might do that headers expose:

  • Domain spoofing: From: ceo@yourcompany.com sent from mail.attacker.xyz. SPF fail will catch this.
  • Display name spoofing: From: "Your Bank" <random@gmail.com>. The domain passes SPF (gmail.com is legitimate) but the display name is fake. Only a header check reveals it.
  • Reply-To hijacking: The From looks legitimate, but replies go to attacker@protonmail.com. A user who hits Reply never notices.
  • Lookalike domains: From: admin@paypa1.com — uses a numeral 1 instead of the letter l. Passes SPF for paypa1.com because that domain is actually registered by the attacker.

Hands-on lab: parse a suspicious .eml file with Python

Setup

You need Python 3 and a WSL terminal. No external packages required — everything used here is in Python's standard library.

First, create a working directory:

mkdir ~/email-header-lab && cd ~/email-header-lab

Create the sample phishing email. This is a realistic fake — do not use a real email with personal data:

cat > suspicious.eml << 'EOF'
Received: from mail.suspicious-domain.xyz (mail.suspicious-domain.xyz [203.0.113.42])
        by mx.example.com with ESMTP id abc123
        for <victim@example.com>; Mon, 10 Apr 2024 09:15:03 +0100
Received: from localhost (localhost [127.0.0.1])
        by mail.suspicious-domain.xyz with SMTP id xyz789;
        Mon, 10 Apr 2024 09:14:58 +0100
Authentication-Results: mx.example.com;
       spf=fail (sender IP is 203.0.113.42) smtp.mailfrom=ceo@yourcompany.com;
       dkim=fail header.d=yourcompany.com;
       dmarc=fail action=none header.from=yourcompany.com
DKIM-Signature: v=1; a=rsa-sha256; d=suspicious-domain.xyz; s=default;
        h=from:to:subject:date; bh=abc123==; b=fakesignature==
From: "CEO - Your Company" <ceo@yourcompany.com>
Reply-To: attacker-collect@protonmail.com
To: victim@example.com
Subject: Urgent: Immediate Wire Transfer Required
Date: Mon, 10 Apr 2024 09:14:55 +0100
Message-ID: <random-id-12345@suspicious-domain.xyz>
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8

Hi,

I need you to process an urgent wire transfer of $45,000 to a new vendor.
Please action this immediately and confirm when done.

Thanks
EOF

Create the Python parser script:

touch analyse_headers.py

Open it in your preferred editor and paste in the following code:

# analyse_headers.py
# Parses a .eml file and produces a triage summary of email header red flags.

import email
import sys
import re

def load_email(path):
    """Open a .eml file and return a parsed email object."""
    with open(path, "r", encoding="utf-8") as f:
        return email.message_from_file(f)

def extract_key_fields(msg):
    """Pull the fields most relevant to phishing triage."""
    return {
        "From": msg.get("From", "NOT PRESENT"),
        "Reply-To": msg.get("Reply-To", "NOT PRESENT"),
        "To": msg.get("To", "NOT PRESENT"),
        "Subject": msg.get("Subject", "NOT PRESENT"),
        "Date": msg.get("Date", "NOT PRESENT"),
        "Message-ID": msg.get("Message-ID", "NOT PRESENT"),
        "Authentication-Results": msg.get("Authentication-Results", "NOT PRESENT"),
    }

def get_received_chain(msg):
    """Return all Received headers as a list, in the order they appear (top = most recent)."""
    # email.message_from_file returns only the last value for duplicate keys
    # We need all Received headers, so we iterate the raw items
    return [value for key, value in msg.items() if key.lower() == "received"]

def check_auth_results(auth_header):
    """
    Parse the Authentication-Results header and return a dict of SPF/DKIM/DMARC verdicts.
    Returns 'MISSING' if the header is not present.
    """
    if auth_header == "NOT PRESENT":
        return {"spf": "MISSING", "dkim": "MISSING", "dmarc": "MISSING"}

    results = {}
    # Look for each protocol result using a simple pattern match
    for protocol in ["spf", "dkim", "dmarc"]:
        match = re.search(rf"{protocol}=(\S+)", auth_header, re.IGNORECASE)
        results[protocol] = match.group(1).rstrip(";") if match else "NOT FOUND"
    return results

def check_reply_to_mismatch(from_field, reply_to_field):
    """
    Check whether the Reply-To domain differs from the From domain.
    A mismatch is a common phishing signal.
    """
    if reply_to_field == "NOT PRESENT":
        return False  # No Reply-To at all is normal

    # Extract just the domain from each address using a basic pattern
    from_domain_match = re.search(r"@([\w.\-]+)", from_field)
    reply_domain_match = re.search(r"@([\w.\-]+)", reply_to_field)

    if not from_domain_match or not reply_domain_match:
        return False

    from_domain = from_domain_match.group(1).lower()
    reply_domain = reply_domain_match.group(1).lower()

    return from_domain != reply_domain

def check_message_id_domain(message_id, from_field):
    """
    The domain in the Message-ID should match the From domain.
    A mismatch suggests the message did not originate where it claims.
    """
    mid_domain_match = re.search(r"@([\w.\-]+)>?$", message_id)
    from_domain_match = re.search(r"@([\w.\-]+)", from_field)

    if not mid_domain_match or not from_domain_match:
        return False  # Cannot determine — not flagged

    mid_domain = mid_domain_match.group(1).lower()
    from_domain = from_domain_match.group(1).lower()

    return mid_domain != from_domain

def print_triage_report(fields, auth, received_chain, flags):
    """Print a formatted triage summary to the terminal."""
    print("\n" + "=" * 60)
    print("  EMAIL HEADER TRIAGE REPORT")
    print("=" * 60)

    print("\n[KEY FIELDS]")
    for key, value in fields.items():
        # Truncate long values for readability
        display = value if len(value) < 80 else value[:77] + "..."
        print(f"  {key:<25} {display}")

    print("\n[AUTHENTICATION RESULTS]")
    for protocol, result in auth.items():
        # Highlight failures clearly
        status = "✗ FAIL" if "fail" in result.lower() else ("? " + result if result in ("MISSING", "NOT FOUND") else "✓ " + result)
        print(f"  {protocol.upper():<10} {status}")

    print(f"\n[RECEIVED CHAIN] — {len(received_chain)} hop(s) found (top = most recent)")
    for i, hop in enumerate(received_chain):
        # Print just the first line of each Received header to keep output clean
        first_line = hop.strip().split("\n")[0]
        print(f"  Hop {i+1}: {first_line[:75]}")

    print("\n[RED FLAGS]")
    if not flags:
        print("  None detected by this parser.")
    for flag in flags:
        print(f"  ⚠  {flag}")

    print("\n" + "=" * 60 + "\n")

def analyse(path):
    """Main analysis function — loads the email and runs all checks."""
    msg = load_email(path)
    fields = extract_key_fields(msg)
    received_chain = get_received_chain(msg)
    auth = check_auth_results(fields["Authentication-Results"])

    flags = []

    # Check 1: SPF failure
    if "fail" in auth.get("spf", "").lower():
        flags.append("SPF FAIL — sending IP not authorised for the From domain.")

    # Check 2: DKIM failure
    if "fail" in auth.get("dkim", "").lower():
        flags.append("DKIM FAIL — signature missing or message tampered with.")

    # Check 3: DMARC failure
    if "fail" in auth.get("dmarc", "").lower():
        flags.append("DMARC FAIL — message did not pass domain alignment checks.")

    # Check 4: Reply-To mismatch
    if check_reply_to_mismatch(fields["From"], fields["Reply-To"]):
        flags.append(
            f"REPLY-TO MISMATCH — From domain differs from Reply-To domain.\n"
            f"     From:     {fields['From']}\n"
            f"     Reply-To: {fields['Reply-To']}"
        )

    # Check 5: Message-ID domain mismatch
    if check_message_id_domain(fields["Message-ID"], fields["From"]):
        flags.append(
            f"MESSAGE-ID MISMATCH — Message-ID domain differs from From domain.\n"
            f"     From:       {fields['From']}\n"
            f"     Message-ID: {fields['Message-ID']}"
        )

    # Check 6: No Authentication-Results header at all
    if fields["Authentication-Results"] == "NOT PRESENT":
        flags.append("NO AUTHENTICATION-RESULTS — header absent; could indicate direct injection or misconfigured relay.")

    print_triage_report(fields, auth, received_chain, flags)

if __name__ == "__main__":
    if len(sys.argv) != 2:
        print("Usage: python3 analyse_headers.py <path-to-email.eml>")
        sys.exit(1)

    analyse(sys.argv[1])

Step 1 — run the parser

python3 analyse_headers.py suspicious.eml

What you should see

============================================================
  EMAIL HEADER TRIAGE REPORT
============================================================

[KEY FIELDS]
  From                      "CEO - Your Company" <ceo@yourcompany.com>
  Reply-To                  attacker-collect@protonmail.com
  To                        victim@example.com
  Subject                   Urgent: Immediate Wire Transfer Required
  Date                      Mon, 10 Apr 2024 09:14:55 +0100
  Message-ID                <random-id-12345@suspicious-domain.xyz>
  Authentication-Results    mx.example.com;        spf=fail (sender IP is 20...

[AUTHENTICATION RESULTS]
  SPF        ✗ FAIL
  DKIM       ✗ FAIL
  DMARC      ✗ FAIL

[RECEIVED CHAIN] — 2 hop(s) found (top = most recent)
  Hop 1: from mail.suspicious-domain.xyz (mail.suspicious-domain.xyz [203.0.1
  Hop 2: from localhost (localhost [127.0.0.1])

[RED FLAGS]
  ⚠  SPF FAIL — sending IP not authorised for the From domain.
  ⚠  DKIM FAIL — signature missing or message tampered with.
  ⚠  DMARC FAIL — message did not pass domain alignment checks.
  ⚠  REPLY-TO MISMATCH — From domain differs from Reply-To domain.
       From:     "CEO - Your Company" <ceo@yourcompany.com>
       Reply-To: attacker-collect@protonmail.com
  ⚠  MESSAGE-ID MISMATCH — Message-ID domain differs from From domain.
       From:       "CEO - Your Company" <ceo@yourcompany.com>
       Message-ID: <random-id-12345@suspicious-domain.xyz>

============================================================

Five red flags on a single email. In a real CSIRT queue, this combination — SPF fail + DKIM fail + Reply-To hijack + Message-ID mismatch — is a clear escalation trigger.

Step 2 — test with a clean email

To confirm the parser does not over-flag, create a clean sample:

cat > clean.eml << 'EOF'
Received: from mail.yourcompany.com (mail.yourcompany.com [198.51.100.10])
        by mx.example.com with ESMTP id def456
        for <colleague@example.com>; Mon, 10 Apr 2024 10:00:00 +0100
Authentication-Results: mx.example.com;
       spf=pass smtp.mailfrom=sender@yourcompany.com;
       dkim=pass header.d=yourcompany.com;
       dmarc=pass action=none header.from=yourcompany.com
From: Sender Name <sender@yourcompany.com>
To: colleague@example.com
Subject: Meeting notes
Date: Mon, 10 Apr 2024 10:00:00 +0100
Message-ID: <legit-id-99999@yourcompany.com>

Hi, here are the notes from today's meeting.
EOF
python3 analyse_headers.py clean.eml

The [RED FLAGS] section should report: None detected by this parser.

What could go wrong

The Received chain can be partially forged. An attacker can prepend fake Received: headers before their own mail server injects the message. Your mail server (the one that writes the topmost Received: header) is the only one you fully trust — it is the entry point you control. When tracing origin IPs, focus on the last external hop before your own server.

SPF pass does not mean the email is safe. A message from attacker@gmail.com will pass SPF for gmail.com — because Gmail's servers are authorised to send for gmail.com. SPF only validates the sending infrastructure, not the sender's identity. This is why display name spoofing works even against SPF-enabled domains.

DMARC with action=none is common. Many organisations publish DMARC records in monitoring mode before committing to enforcement. A dmarc=fail action=none means the domain has DMARC but is not rejecting non-compliant mail yet. The email still gets delivered. From a triage perspective, it is still a fail — flag it.

This parser is a starting point, not a production tool. It covers the most common signals. Real CSIRT tooling (XSOAR playbooks, SIEM correlation rules) layers in threat intelligence feeds, URL reputation checks, attachment sandboxing, and historical sender behaviour. Think of this script as the manual version of what those tools automate.

Key takeaways

  • The From: field in email is not authenticated — it can be trivially spoofed. Never use it alone to judge legitimacy.
  • The Authentication-Results: header, written by your mail server, is your primary triage signal. SPF, DKIM, and DMARC failures each point to specific problems.
  • A Reply-To that differs from From domain is one of the clearest signals of reply-harvesting phishing.
  • The Received: chain traces the actual path of the message — read it bottom to top to find the origin.
  • Python's standard library email module is enough to build a functional triage tool with no external dependencies.