Skip to main content

Automating Phishing Header Analysis in Your PSA

· 24 min read
tacticalBeard
Automation Enthusiast

When a user reports a phishing email, the ticket lands and someone has to deal with it. Without automation that means: download the .eml, open it in a text editor, read through several hundred lines of raw headers, manually pull SPF/DKIM/DMARC verdicts, and write up a note. Every analyst does it slightly differently. Some do it thoroughly. Some do it fast. Most do it inconsistently at 4pm on a Friday.

I built an automation that fires the moment the ticket is created, parses the attached .eml, evaluates authentication headers, and posts a structured triage summary back to the ticket (usually within a few seconds of the ticket opening). Here is what it does and the part that would have burned me if I had not caught it.

The Problem

Phishing triage is one of those tasks that looks simple until you are doing twenty of them. The actual header analysis is not complicated (you are looking at three fields and a few supporting headers). The problem is consistency and time. A manual process produces notes that range from "SPF fail, looks phishy" to a full chain-of-custody breakdown depending on who ran the ticket. Neither extreme is useful at scale. You want every phishing ticket to have the same structure, the same fields evaluated, the same verdict format (automatically, before an analyst even opens the ticket).

The secondary problem is documentation. When a phishing campaign hits and you need to correlate ten tickets, you want machine-readable data, not analyst prose.

Prerequisites

This implementation runs on ConnectWise PSA with the PIA (Process Intelligence Automation) add-on. Both are required as-described. If you are not on ConnectWise, the analysis logic (the PowerShell in the Full Script section) is PSA-agnostic — what changes is how you trigger it and how you pass credentials and ticket context to it.

Most PSAs that support automation have some equivalent to a workflow or runbook that fires on ticket creation. The trigger condition (filtering for phishing-classified tickets), the mechanism for injecting API credentials, and the API calls to retrieve attachments and post notes will all look different depending on your platform. The core pattern is the same: detect the ticket, download the .eml, run the script, write the outputs back. PIA just handles all of that natively with minimal glue.

What the Automation Does

The trigger is ticket creation. PIA monitors for tickets classified as Incident, Security, or Phishing and starts the workflow automatically using the ai_autostart condition with autostart_classification. No webhook, no external call, no scheduled job.

When the workflow fires, PIA injects the ticket context and ConnectWise API credentials directly as input variables. An inline PowerShell step uses those variables to connect to the ConnectWise REST API, retrieve the ticket's document attachments, filter for the .eml file, and download the raw EML content directly into memory. The analysis runs in that same step. When it finishes, two outputs are written back to the ticket:

  1. A plain-text note with the triage summary (authentication verdicts prominently labeled, key headers surfaced, a simple verdict line at the top).
  2. An HTML file attached to the ticket (a styled full-header breakdown with color-coded authentication results, useful for escalations and documentation).

After both are posted, the original .eml attachment is deleted from the ticket. An analyst opening the ticket should not be able to accidentally open a potentially malicious file.

The whole round-trip takes under ten seconds on typical phishing emails.

Parsing the EML

An .eml file is plain text. Headers live above the first blank line; the message body follows. MIME multipart messages can have additional structure below that, but for header analysis you only need the top section.

Folded headers are the one thing worth knowing about. RFC 2822 allows long header values to be split across multiple lines, where continuation lines start with whitespace. A naive line-by-line split will truncate those values. The parser handles unfolding:

# Split headers from body at the first blank line
$RawParts = $EmlContent -split "(?m)^$", 2
$RawHeaders = $RawParts[0]

# Parse headers into a hashtable, handling folded continuation lines
$Headers = @{}
$CurrentKey = $null
foreach ($Line in ($RawHeaders -split "`r?`n")) {
if ($Line -match '^([\w-]+):\s*(.*)') {
$CurrentKey = $Matches[1]
$Headers[$CurrentKey] = $Matches[2].Trim()
} elseif ($Line -match '^\s+' -and $CurrentKey) {
$Headers[$CurrentKey] += ' ' + $Line.Trim()
}
}

After this runs, $Headers is a hashtable keyed by header name. Folded values are collapsed into single strings. Everything downstream works off this hashtable.

SPF, DKIM, and DMARC Evaluation

Authentication verdicts are published in the Authentication-Results header, added by the receiving mail server after it evaluates the message. Pulling them out is straightforward regex work:

$AuthHeader = $Headers['Authentication-Results']

$SPF = if ($AuthHeader -match 'spf=(pass|fail|softfail|neutral|none|permerror|temperror)') { $Matches[1] } else { 'not found' }
$DKIM = if ($AuthHeader -match 'dkim=(pass|fail|none|permerror|temperror)') { $Matches[1] } else { 'not found' }
$DMARC = if ($AuthHeader -match 'dmarc=(pass|fail|none|bestguesspass)') { $Matches[1] } else { 'not found' }

This works correctly for direct delivery. It does not work correctly when a filtering gateway is in the path. That is the part that would have burned me.

Gateway Overwrite Problem

If inbound mail routes through a filtering gateway (Proofpoint Essentials, for example), the Authentication-Results header in the delivered .eml reflects the gateway's own re-evaluation against its own receiving domain, not the original sending domain's authentication chain. You can end up with SPF pass on mail that failed against the sender's domain because the gateway does its own SPF check against itself.

Proofpoint Essentials preserves the pre-gateway authentication results in a separate header: Authentication-Results-Original. Parsing Authentication-Results alone on Proofpoint-processed mail will produce misleading verdicts.

The fix is to check for Authentication-Results-Original first and prefer it when present:

# Prefer pre-gateway auth results when a filtering gateway is in the path
$AuthHeader = if ($Headers['Authentication-Results-Original']) {
$Headers['Authentication-Results-Original']
} else {
$Headers['Authentication-Results']
}

One line of logic that completely changes the accuracy of every verdict the automation produces on Proofpoint-routed mail. If you run a filtering gateway (or if any of your clients do), check whether it preserves pre-gateway auth results in a separate header and account for it.

Other headers worth capturing alongside authentication verdicts: From, Reply-To, Return-Path, Received (for the originating IP), Subject, and X-Originating-IP if present. Mismatches between From domain and Reply-To domain are worth flagging explicitly in the output (that pattern shows up constantly in credential harvesting attempts).

Building the Triage Output

The plain-text note is what an analyst reads first. It leads with authentication results labeled and flagged where they fail, surfaces the key headers that matter for a first-pass verdict, and ends with a single verdict line. Flagged results (fail, softfail, none, not found) get visual markers so they stand out when the analyst is scanning quickly.

=== Phishing Header Analysis ===
File: suspicious-email.eml

AUTHENTICATION
SPF: FAIL (expected: pass)
DKIM: PASS
DMARC: FAIL

KEY HEADERS
From: "Finance Dept" <[email protected]>
Reply-To: [email protected] [MISMATCH - different domain]
Return-Path: [email protected]
Sending IP: 203.0.113.42
Subject: Urgent: Invoice Payment Required

VERDICT: Authentication failures detected. Recommend quarantine and user notification.
Full report attached as HTML.

The HTML attachment handles the full header dump. Every header name and value, in a two-column table, with Authentication-Results rows color-coded green for pass and red for fail/softfail/none. It is not meant to be read start to finish (it exists so that when a ticket gets escalated or you need to correlate it with another report, everything is there — the original .eml is deleted from the ticket after analysis, so the HTML report is the permanent record).

Building the HTML in PowerShell is straightforward here-string work. The only thing worth noting is that you want to HTML-encode the header values before inserting them into the table (raw headers from attacker-controlled mail can contain characters that will break your markup or, depending on your PSA's rendering pipeline, cause other problems).

Why This Matters

The value here is not speed, though the speed is real. The value is that every phishing ticket now has the same structure, the same fields, the same verdict format (regardless of who is on the queue, what time it is, or whether the analyst has done twenty of these today or their first one this month).

That consistency compounds. When a campaign comes through and you have twelve tickets reporting the same sender domain, you can query across ticket notes programmatically. When a user escalates a ticket that was closed as non-malicious, you have documented verdicts with timestamps, not analyst recollections. When a new analyst joins the team, they learn what to look for by reading the automated output, not by asking someone.

The gateway complication I described above is the kind of thing that only surfaces when you actually run the automation against real mail and audit the results. I caught it because I had a known-bad phishing sample that should have shown SPF fail, and the automation was returning SPF pass. Traced it back to the Authentication-Results header being overwritten by Proofpoint. That is the sort of subtle accuracy problem that manual triage would never surface consistently (it would just produce wrong verdicts quietly, on some percentage of tickets, forever).

Build the automation. Audit it against known samples. The headers do not lie, but the gateway might rewrite them before you get there.


Full Script

<#
.SYNOPSIS
Analyzes email headers from a .eml file and produces a structured
triage summary plus an optional styled HTML report.

.DESCRIPTION
Parses raw MIME headers from an .eml file, evaluates SPF/DKIM/DMARC
authentication results (with awareness of email security gateway header
rewriting), identifies common spoofing/phishing indicators, and outputs
a triage-ready summary.

Designed to be called from a PSA automation workflow that downloads the
.eml from a ticket attachment. The resulting summary text and HTML report
can then be posted back to the ticket via your PSA's API. Auth credentials
and API endpoints for your specific PSA belong in your automation
platform's credential store, not in this script.

.PARAMETER EmlPath
Path to the .eml file to analyze.

.PARAMETER OutputHtml
Optional. If specified, writes a styled HTML report to this path.

.EXAMPLE
# Analyze only — triage summary printed to stdout
.\Analyze-PhishingEmail.ps1 -EmlPath "C:\Temp\suspicious.eml"

.EXAMPLE
# Analyze and generate HTML report
.\Analyze-PhishingEmail.ps1 -EmlPath "C:\Temp\suspicious.eml" -OutputHtml "C:\Temp\report.html"

.NOTES
Handles folded (multi-line) headers per RFC 2822 section 2.2.3.
Handles Proofpoint Essentials gateway header rewriting via
Authentication-Results-Original. Adaptable to other gateways that
preserve pre-gateway auth data in a similar header.
#>

[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$EmlPath,

[Parameter(Mandatory = $false)]
[string]$OutputHtml
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'


#region ─── Header Parsing ────────────────────────────────────────────────────

function Parse-EmlHeaders {
<#
.SYNOPSIS
Parses raw RFC 2822 headers from an .eml file's header block.
.DESCRIPTION
Splits the raw .eml content on the first blank line to isolate the
header section, then parses each Name: Value pair into a hashtable.

Handles folded (continuation) lines per RFC 2822 — lines that begin
with whitespace are appended to the previous header's value.

Received headers are also collected into an ordered list (_AllReceived)
because multiple Received headers appear in reverse chronological order
and are important for hop analysis.

Note: For headers with multiple occurrences other than Received
(unusual but valid), only the last occurrence is kept in the main
hashtable.
#>
param([string]$RawEml)

# RFC 2822 §2.1: headers and body are separated by the first blank line.
# Split on the first occurrence of a line that is entirely blank.
$Parts = $RawEml -split '(?m)^[ \t]*[\r]?$', 2
$HeaderBlock = $Parts[0]

$Headers = [System.Collections.Hashtable]::new([System.StringComparer]::OrdinalIgnoreCase)
$AllReceived = [System.Collections.Generic.List[string]]::new()
$CurrentKey = $null

foreach ($Line in ($HeaderBlock -split "`r?`n")) {

if ($Line -match '^([\w!#$%&''*+\-/=?^_`{|}~.]+):\s*(.*)') {
# New header field
$CurrentKey = $Matches[1]
$Value = $Matches[2].Trim()
$Headers[$CurrentKey] = $Value

if ($CurrentKey -ieq 'Received') {
$AllReceived.Add($Value)
}
}
elseif ($Line -match '^\s+' -and $null -ne $CurrentKey) {
# Folded continuation line — join with a single space (RFC 2822 §2.2.3)
$Continuation = $Line.Trim()
$Headers[$CurrentKey] += " $Continuation"

if ($CurrentKey -ieq 'Received') {
$AllReceived[$AllReceived.Count - 1] += " $Continuation"
}
}
# Blank lines within the header block (should not occur in a well-formed
# message but appear in malformed ones) — skip silently.
}

# Store ordered Received list under a private key for hop analysis
$Headers['_AllReceived'] = $AllReceived

return $Headers
}

#endregion


#region ─── Authentication Evaluation ────────────────────────────────────────

function Get-AuthResults {
<#
.SYNOPSIS
Extracts SPF, DKIM, DMARC, and CompAuth verdicts from email auth headers.
.DESCRIPTION
EMAIL SECURITY GATEWAY HANDLING:
When inbound mail routes through a filtering gateway (e.g., Proofpoint
Essentials), the Authentication-Results header in the delivered message
reflects the GATEWAY'S OWN domain check — not the original sender's.

Proofpoint Essentials preserves the original authentication verdicts
in Authentication-Results-Original. This script checks that header
first. If present, it is used as the primary source, and the standard
Authentication-Results is retained for comparison only.

Other gateways may use different header names. If your environment
uses a different gateway, adjust the $OriginalAuthHeader lookup below.
Some gateways (Mimecast, Barracuda, Google Workspace) add their own
proprietary headers — check your vendor docs for equivalents.

CompAuth (composite authentication) is a Microsoft-specific verdict
used by Exchange Online Protection / Office 365 and is read from
Authentication-Results when present.
#>
param([System.Collections.Hashtable]$Headers)

# Check for pre-gateway authentication result headers.
# Add additional gateway header names here if your environment uses them.
$PreGatewayHeaderNames = @(
'Authentication-Results-Original', # Proofpoint Essentials
'X-Original-Authentication-Results' # Some other gateways
)

$OriginalAuthHeader = $null
$OriginalAuthSource = $null
foreach ($HdrName in $PreGatewayHeaderNames) {
if ($Headers[$HdrName]) {
$OriginalAuthHeader = $Headers[$HdrName]
$OriginalAuthSource = $HdrName
break
}
}

# Use pre-gateway auth if available; fall back to standard header
$PrimaryAuth = if ($OriginalAuthHeader) { $OriginalAuthHeader } else { $Headers['Authentication-Results'] }
$GatewayPresent = [bool]$OriginalAuthHeader

$Result = [PSCustomObject]@{
SPF = 'not found'
SPFDomain = ''
DKIM = 'not found'
DKIMDomain = ''
DKIM_Selector = ''
DMARC = 'not found'
DMARCPolicy = ''
CompAuth = 'not found'
CompAuthReason = ''
GatewayDetected = $GatewayPresent
SourceHeader = if ($GatewayPresent) { "$OriginalAuthSource (pre-gateway)" } else { 'Authentication-Results' }
RawPrimary = $PrimaryAuth
RawStandard = $Headers['Authentication-Results']
}

if (-not $PrimaryAuth) { return $Result }

# SPF verdict and identity
if ($PrimaryAuth -match 'spf=(pass|fail|softfail|neutral|none|permerror|temperror)') {
$Result.SPF = $Matches[1]
}
if ($PrimaryAuth -match 'smtp\.(?:mailfrom|helo)=([^\s;>]+)') {
$Result.SPFDomain = $Matches[1]
}

# DKIM verdict, signing domain, and selector
if ($PrimaryAuth -match 'dkim=(pass|fail|none|permerror|temperror)') {
$Result.DKIM = $Matches[1]
}
if ($PrimaryAuth -match 'header\.(?:i|d)=(@?[\w.\-]+)') {
$Result.DKIMDomain = $Matches[1].TrimStart('@')
}
if ($PrimaryAuth -match 'header\.s=([\w.\-]+)') {
$Result.DKIM_Selector = $Matches[1]
}

# DMARC verdict and policy applied
if ($PrimaryAuth -match 'dmarc=(pass|fail|none|bestguesspass|temperror|permerror)') {
$Result.DMARC = $Matches[1]
}
if ($PrimaryAuth -match 'policy\.(?:applied|disposition)=(none|quarantine|reject)') {
$Result.DMARCPolicy = $Matches[1]
}

# CompAuth (Exchange Online / EOP composite authentication score)
if ($PrimaryAuth -match 'compauth=(pass|fail|none|softpass)') {
$Result.CompAuth = $Matches[1]
}
if ($PrimaryAuth -match '\breason=(\d{3})') {
$Result.CompAuthReason = $Matches[1]
# Reason codes: 000=pass, 001=spoof, 002=spoof, 010=dmarc, 100=no records, etc.
# See: https://learn.microsoft.com/en-us/microsoft-365/security/office-365-security/anti-spam-message-headers
}

return $Result
}

#endregion


#region ─── Suspicious Indicator Detection ────────────────────────────────────

function Get-SuspiciousIndicators {
<#
.SYNOPSIS
Evaluates parsed headers for common phishing and spoofing indicators.
.DESCRIPTION
Returns a list of human-readable indicator strings. These are
heuristics — each one warrants analyst review but is not definitive
on its own. The combination and context matter.

This function is intentionally conservative: it flags things that
MIGHT be wrong and lets the analyst decide. It avoids automated
verdicts based on heuristics alone.
#>
param(
[System.Collections.Hashtable]$Headers,
[PSCustomObject]$AuthResults
)

$Indicators = [System.Collections.Generic.List[string]]::new()

#── Authentication failures ────────────────────────────────────────────────

if ($AuthResults.SPF -in @('fail', 'softfail', 'permerror')) {
$SufNote = if ($AuthResults.SPF -eq 'softfail') { ' (softfail — sending IP not in authorized range)' }
elseif ($AuthResults.SPF -eq 'fail') { ' (hard fail — sending IP explicitly not authorized)' }
else { ' (DNS lookup error during SPF evaluation)' }
$Indicators.Add("SPF $($AuthResults.SPF.ToUpper())$SufNote")
}

if ($AuthResults.DKIM -in @('fail', 'permerror')) {
$Indicators.Add("DKIM $($AuthResults.DKIM.ToUpper()) — message signature is invalid or the signing key could not be retrieved")
}

if ($AuthResults.DMARC -eq 'fail') {
$PolicyNote = if ($AuthResults.DMARCPolicy) { " — policy applied: $($AuthResults.DMARCPolicy)" } else { '' }
$Indicators.Add("DMARC FAIL — message does not meet domain owner alignment requirements$PolicyNote")
}

if ($AuthResults.CompAuth -eq 'fail') {
$Indicators.Add("CompAuth FAIL (reason=$($AuthResults.CompAuthReason)) — Microsoft composite authentication failed")
}

#── Domain alignment checks ───────────────────────────────────────────────

function Extract-Domain($HeaderValue) {
if ($HeaderValue -match '@([\w.\-]+)[>"\s]?') { return $Matches[1].ToLower() }
return $null
}

$FromDomain = Extract-Domain ($Headers['From'] ?? '')
$ReplyToDomain = Extract-Domain ($Headers['Reply-To'] ?? '')
$ReturnPathDomain = Extract-Domain ($Headers['Return-Path'] ?? '')

if ($ReplyToDomain -and $FromDomain -and ($ReplyToDomain -ine $FromDomain)) {
$Indicators.Add("Reply-To domain ($ReplyToDomain) differs from From domain ($FromDomain) — replies will go to a different organization")
}

if ($ReturnPathDomain -and $FromDomain -and ($ReturnPathDomain -ine $FromDomain)) {
$Indicators.Add("Return-Path domain ($ReturnPathDomain) differs from From domain ($FromDomain)")
}

if ($AuthResults.DKIMDomain -and $FromDomain -and ($AuthResults.DKIMDomain -ine $FromDomain)) {
$Indicators.Add("DKIM signing domain ($($AuthResults.DKIMDomain)) does not match From domain ($FromDomain) — alignment failure")
}

#── Display name spoofing ─────────────────────────────────────────────────

$FromHeader = $Headers['From'] ?? ''
# Check if display name contains high-value impersonation terms
# while the actual email address is from an external/unexpected domain
$ImpersonationTerms = 'payroll|finance|accounting|cfo|ceo|controller|hr |human resources|helpdesk|it support|microsoft|apple|amazon|docusign|dropbox|sharepoint'
if ($FromHeader -match "^`"?([^`"<]+)`"?\s*<" -and $FromHeader -match $ImpersonationTerms) {
$DisplayName = $Matches[1].Trim()
$Indicators.Add("From display name contains a high-value impersonation term: '$DisplayName' — verify sender is expected")
}

#── Message-ID anomalies ──────────────────────────────────────────────────

$MessageID = $Headers['Message-ID'] ?? ''
if (-not $MessageID) {
$Indicators.Add("Message-ID header is absent — uncommon in legitimate mail, often missing in bulk or spoofed messages")
}
elseif ($MessageID -match '@\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}') {
$Indicators.Add("Message-ID domain is a raw IP address ($MessageID) — legitimate MTAs use FQDNs")
}
elseif ($MessageID -match '@localhost') {
$Indicators.Add("Message-ID references localhost — suggests misconfigured or spoofed sending system")
}

#── Unusual or suspicious header combinations ─────────────────────────────

# X-Mailer presence of known spamware or webmail identifiers
$XMailer = $Headers['X-Mailer'] ?? $Headers['User-Agent'] ?? ''
if ($XMailer -match 'phpmailer|swiftmailer|sendgrid|mailchimp|massmailer') {
# These are legitimate tools but worth noting — phishing frequently uses them
$Indicators.Add("X-Mailer indicates bulk/programmatic sending tool: $XMailer")
}

# Received hop count — very few hops can indicate direct injection
$HopCount = $Headers['_AllReceived'].Count
if ($HopCount -eq 1) {
$Indicators.Add("Only 1 Received header present — message may have been injected directly into your mail system")
}

return $Indicators
}

#endregion


#region ─── Output: Plain-Text Triage Summary ────────────────────────────────

function Format-TriageSummary {
<#
.SYNOPSIS
Formats the plain-text triage note suitable for posting to a PSA ticket.
.DESCRIPTION
Produces a concise, structured summary that lets an analyst make a
triage decision without reading raw headers. Authentication verdicts
are prominently displayed, indicators are listed with [!] prefix,
and a verdict classification is given at the top.
#>
param(
[System.Collections.Hashtable]$Headers,
[PSCustomObject]$AuthResults,
[System.Collections.Generic.List[string]]$Indicators,
[string]$FileName
)

# Classify overall verdict
$IsHighRisk = (
$AuthResults.DMARC -eq 'fail' -or
$AuthResults.SPF -eq 'fail' -or
$AuthResults.DKIM -eq 'fail' -or
$Indicators.Count -ge 3
)
$IsMedRisk = (
$Indicators.Count -ge 1 -or
$AuthResults.SPF -eq 'softfail' -or
$AuthResults.DKIM -eq 'none'
)

$Verdict = if ($IsHighRisk) { 'HIGH SUSPICION — Recommend quarantine and user notification' }
elseif ($IsMedRisk) { 'REVIEW REQUIRED — Minor indicators present, analyst judgment needed' }
else { 'LIKELY CLEAN — No major indicators detected' }

$SB = [System.Text.StringBuilder]::new()
$Line = '=' * 62

$null = $SB.AppendLine($Line)
$null = $SB.AppendLine('PHISHING HEADER ANALYSIS')
$null = $SB.AppendLine("File : $FileName")
$null = $SB.AppendLine("Analyzed: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') UTC")
$null = $SB.AppendLine($Line)
$null = $SB.AppendLine()
$null = $SB.AppendLine("VERDICT: $Verdict")
$null = $SB.AppendLine()

# Authentication block
$null = $SB.AppendLine("AUTHENTICATION [Source: $($AuthResults.SourceHeader)]")
$null = $SB.AppendLine(" SPF : $($AuthResults.SPF.ToUpper().PadRight(12)) $(if($AuthResults.SPFDomain){"| domain: $($AuthResults.SPFDomain)"})")
$null = $SB.AppendLine(" DKIM : $($AuthResults.DKIM.ToUpper().PadRight(12)) $(if($AuthResults.DKIMDomain){"| d=$($AuthResults.DKIMDomain)"})")
$null = $SB.AppendLine(" DMARC : $($AuthResults.DMARC.ToUpper().PadRight(12)) $(if($AuthResults.DMARCPolicy){"| policy=$($AuthResults.DMARCPolicy)"})")
if ($AuthResults.CompAuth -ne 'not found') {
$null = $SB.AppendLine(" CompAuth: $($AuthResults.CompAuth.ToUpper()) (reason=$($AuthResults.CompAuthReason))")
}
if ($AuthResults.GatewayDetected) {
$null = $SB.AppendLine(" ** Email gateway in path — results reflect pre-gateway authentication **")
}
$null = $SB.AppendLine()

# Key headers
$null = $SB.AppendLine('KEY HEADERS')
$null = $SB.AppendLine(" From : $($Headers['From'] ?? '(not present)')")
$null = $SB.AppendLine(" Reply-To : $($Headers['Reply-To'] ?? '(not set)')")
$null = $SB.AppendLine(" Return-Path : $($Headers['Return-Path'] ?? '(not present)')")
$null = $SB.AppendLine(" Subject : $($Headers['Subject'] ?? '(not present)')")
$null = $SB.AppendLine(" Date : $($Headers['Date'] ?? '(not present)')")
$null = $SB.AppendLine(" Message-ID : $($Headers['Message-ID'] ?? '(not present)')")
$null = $SB.AppendLine()

# Indicators
if ($Indicators.Count -gt 0) {
$null = $SB.AppendLine("INDICATORS ($($Indicators.Count) found)")
foreach ($Ind in $Indicators) {
$null = $SB.AppendLine(" [!] $Ind")
}
}
else {
$null = $SB.AppendLine('INDICATORS: None detected')
}

$null = $SB.AppendLine()
$null = $SB.AppendLine('Full header report attached as HTML.')
$null = $SB.AppendLine($Line)

return $SB.ToString()
}

#endregion


#region ─── Output: Styled HTML Report ───────────────────────────────────────

function Export-HtmlReport {
<#
.SYNOPSIS
Generates a self-contained styled HTML report of the full analysis.
.DESCRIPTION
All CSS is inline — no external dependencies. Safe to attach to tickets
or email. Color-codes authentication verdicts and highlights indicators.
#>
param(
[System.Collections.Hashtable]$Headers,
[PSCustomObject]$AuthResults,
[System.Collections.Generic.List[string]]$Indicators,
[string]$FileName,
[string]$OutputPath
)

Add-Type -AssemblyName System.Web

function Get-AuthColor($v) {
switch ($v.ToLower()) {
'pass' { '#1b5e20' }
'bestguesspass' { '#2e7d32' }
'softpass' { '#558b2f' }
'softfail' { '#e65100' }
'fail' { '#b71c1c' }
'permerror' { '#b71c1c' }
'temperror' { '#e65100' }
'neutral' { '#37474f' }
'none' { '#37474f' }
'not found' { '#9e9e9e' }
default { '#212121' }
}
}

function HE($s) { [System.Web.HttpUtility]::HtmlEncode($s ?? '') }

$IsHighRisk = $AuthResults.DMARC -eq 'fail' -or $AuthResults.SPF -eq 'fail' -or $AuthResults.DKIM -eq 'fail' -or $Indicators.Count -ge 3
$IsMedRisk = $Indicators.Count -ge 1 -or $AuthResults.SPF -eq 'softfail'
$VerdictText = if ($IsHighRisk) {'HIGH SUSPICION'} elseif ($IsMedRisk) {'REVIEW REQUIRED'} else {'LIKELY CLEAN'}
$VerdictColor = if ($IsHighRisk) {'#b71c1c'} elseif ($IsMedRisk) {'#e65100'} else {'#1b5e20'}

# Build authentication table rows
$AuthRows = @(
@{ Proto='SPF'; Val=$AuthResults.SPF; Detail=$(if($AuthResults.SPFDomain){"domain: $($AuthResults.SPFDomain)"}else{''}) }
@{ Proto='DKIM'; Val=$AuthResults.DKIM; Detail=$(if($AuthResults.DKIMDomain){"d=$($AuthResults.DKIMDomain) s=$($AuthResults.DKIM_Selector)"}else{''}) }
@{ Proto='DMARC'; Val=$AuthResults.DMARC; Detail=$(if($AuthResults.DMARCPolicy){"policy=$($AuthResults.DMARCPolicy)"}else{''}) }
)
if ($AuthResults.CompAuth -ne 'not found') {
$AuthRows += @{ Proto='CompAuth'; Val=$AuthResults.CompAuth; Detail="reason=$($AuthResults.CompAuthReason)" }
}
$AuthHtml = ($AuthRows | ForEach-Object {
$c = Get-AuthColor $_.Val
"<tr><td class='label'>$($_.Proto)</td>" +
"<td style='color:$c;font-weight:600'>$($_.Val.ToUpper())</td>" +
"<td class='detail'>$(HE $_.Detail)</td></tr>"
}) -join "`n"

$GatewayNote = if ($AuthResults.GatewayDetected) {
"<div class='note-banner'>&#9432;&nbsp; Email security gateway detected in the path. " +
"Authentication results are sourced from <code>$($AuthResults.SourceHeader)</code> " +
"(pre-gateway values). Standard <code>Authentication-Results</code> reflects gateway's own check.</div>"
} else { '' }

# Indicator list
$IndicatorHtml = if ($Indicators.Count -gt 0) {
$Items = ($Indicators | ForEach-Object { "<li>$(HE $_)</li>" }) -join "`n"
"<ul class='ind-list'>$Items</ul>"
} else {
"<p class='clean-msg'>&#10003;&nbsp; No suspicious indicators detected.</p>"
}

# Received chain (stored newest-first as .eml convention)
$ReceivedHtml = if ($Headers['_AllReceived'].Count -gt 0) {
$Items = ($Headers['_AllReceived'] | ForEach-Object { "<li><code>$(HE $_)</code></li>" }) -join "`n"
"<ol class='recv-chain'>$Items</ol>"
} else { '<p class="muted">No Received headers found.</p>' }

# All headers table (sorted, skip private list key)
$AllHdrHtml = ($Headers.Keys |
Where-Object { $_ -ne '_AllReceived' } |
Sort-Object |
ForEach-Object {
"<tr><td class='hk'>$(HE $_)</td><td class='hv'>$(HE $Headers[$_])</td></tr>"
}) -join "`n"

$Html = @"
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Header Analysis — $(HE $FileName)</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Segoe UI',Arial,sans-serif;font-size:13px;background:#f0f2f5;color:#212121;padding:20px}
.wrap{max-width:960px;margin:0 auto}
.card{background:#fff;border-radius:6px;box-shadow:0 1px 3px rgba(0,0,0,.12);padding:20px 24px;margin-bottom:16px}
h1{font-size:18px;font-weight:600;margin-bottom:4px}
h2{font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:#546e7a;
border-bottom:1px solid #eceff1;padding-bottom:6px;margin-bottom:14px}
.verdict{font-size:22px;font-weight:700;color:$VerdictColor;margin-top:6px}
.meta{color:#78909c;font-size:12px;margin-bottom:10px}
table{width:100%;border-collapse:collapse}
th,td{padding:5px 10px;text-align:left;border-bottom:1px solid #f5f5f5;vertical-align:top}
th{background:#fafafa;font-weight:600;font-size:12px;color:#546e7a;width:180px}
.label{font-weight:600;font-size:13px;width:90px}
.detail{color:#546e7a;font-size:12px}
.hk{font-family:monospace;font-size:11px;color:#546e7a;white-space:nowrap;width:200px}
.hv{font-family:monospace;font-size:11px;word-break:break-all}
.ind-list{padding-left:20px;margin-top:4px}
.ind-list li{color:#b71c1c;margin-bottom:6px;line-height:1.5}
.clean-msg{color:#1b5e20;font-weight:500}
.muted{color:#9e9e9e;font-style:italic}
.note-banner{background:#fff8e1;border-left:4px solid #ffc107;padding:10px 14px;
border-radius:0 4px 4px 0;font-size:12px;margin-bottom:12px;line-height:1.5}
.recv-chain{padding-left:20px}
.recv-chain li{margin-bottom:8px}
.recv-chain code{font-size:11px;word-break:break-all;background:#f5f5f5;
padding:3px 6px;border-radius:3px;display:block;line-height:1.5}
code{font-size:11px;background:#f5f5f5;padding:1px 5px;border-radius:3px}
</style>
</head>
<body>
<div class="wrap">

<div class="card">
<h1>Phishing Header Analysis</h1>
<div class="meta">$(HE $FileName) &nbsp;&#124;&nbsp; Analyzed: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') UTC</div>
<div class="verdict">$VerdictText</div>
</div>

<div class="card">
<h2>Authentication Results</h2>
$GatewayNote
<table>
<tr><th class="label">Protocol</th><th>Result</th><th>Details</th></tr>
$AuthHtml
</table>
</div>

<div class="card">
<h2>Suspicious Indicators</h2>
$IndicatorHtml
</div>

<div class="card">
<h2>Key Headers</h2>
<table>
<tr><th>From</th><td>$(HE ($Headers['From'] ?? '(not present)'))</td></tr>
<tr><th>Reply-To</th><td>$(HE ($Headers['Reply-To'] ?? '(not set)'))</td></tr>
<tr><th>Return-Path</th><td>$(HE ($Headers['Return-Path'] ?? '(not present)'))</td></tr>
<tr><th>Subject</th><td>$(HE ($Headers['Subject'] ?? '(not present)'))</td></tr>
<tr><th>Date</th><td>$(HE ($Headers['Date'] ?? '(not present)'))</td></tr>
<tr><th>Message-ID</th><td>$(HE ($Headers['Message-ID'] ?? '(not present)'))</td></tr>
<tr><th>X-Mailer / User-Agent</th><td>$(HE ($Headers['X-Mailer'] ?? $Headers['User-Agent'] ?? '(not present)'))</td></tr>
<tr><th>X-Originating-IP</th><td>$(HE ($Headers['X-Originating-IP'] ?? $Headers['X-Source-IP'] ?? '(not present)'))</td></tr>
<tr><th>MIME-Version</th><td>$(HE ($Headers['MIME-Version'] ?? '(not present)'))</td></tr>
</table>
</div>

<div class="card">
<h2>Received Chain (newest first)</h2>
$ReceivedHtml
</div>

<div class="card">
<h2>All Parsed Headers</h2>
<table>
<tr><th class="hk">Header</th><th class="hv">Value</th></tr>
$AllHdrHtml
</table>
</div>

</div>
</body>
</html>
"@

[System.IO.File]::WriteAllText($OutputPath, $Html, [System.Text.Encoding]::UTF8)
Write-Verbose "HTML report written to: $OutputPath"
}

#endregion


#region ─── Main ──────────────────────────────────────────────────────────────

if (-not (Test-Path -LiteralPath $EmlPath)) {
Write-Error "EML file not found: $EmlPath"
exit 1
}

$FileName = Split-Path $EmlPath -Leaf
$RawEml = [System.IO.File]::ReadAllText($EmlPath, [System.Text.Encoding]::UTF8)

# Parse, evaluate, detect
$Headers = Parse-EmlHeaders -RawEml $RawEml
$AuthResults = Get-AuthResults -Headers $Headers
$Indicators = Get-SuspiciousIndicators -Headers $Headers -AuthResults $AuthResults

# Generate triage summary (capture this in your PSA automation and post as ticket note)
$TriageSummary = Format-TriageSummary `
-Headers $Headers `
-AuthResults $AuthResults `
-Indicators $Indicators `
-FileName $FileName

Write-Output $TriageSummary

# Generate HTML report if requested
if ($OutputHtml) {
Export-HtmlReport `
-Headers $Headers `
-AuthResults $AuthResults `
-Indicators $Indicators `
-FileName $FileName `
-OutputPath $OutputHtml
}

<#
═══════════════════════════════════════════════════════════════════════════════
HOW THIS SCRIPT FITS INTO THE PRODUCTION WORKFLOW
═══════════════════════════════════════════════════════════════════════════════

This file is the standalone, local-execution version of the analysis logic.
Pass -EmlPath to run it directly against a saved .eml file for ad-hoc
analysis or testing outside of any automation platform.

The production deployment runs the equivalent logic as an inline PowerShell
step (task: inline_powershell) inside a PIA (Process Intelligence Automation)
YAML workflow in ConnectWise PSA. Key differences from this standalone version:

TRIGGERING
PIA triggers the workflow automatically on tickets classified as Incident,
Security, or Phishing using the ai_autostart condition with
autostart_classification. No webhook or external call initiates it.

CREDENTIALS AND TICKET CONTEXT
PIA injects the ConnectWise API credentials and ticket context directly as
workflow input variables: $Ctx_Ticket_Id, $CW_Api_Client_Id,
$CW_Api_Token, and member identity variables. There is no external
credential store, config file, or secret manager involved — the inline
script receives these values as part of the PIA execution environment.

EXECUTION MODEL
The inline script uses the injected variables to connect to the
ConnectWise REST API, retrieve ticket documents, filter for .eml
attachments, download the raw EML content directly into memory, run
the header analysis, post the plain-text summary as a ticket note, and
upload the styled HTML report as a document attachment — all within a
single PIA execution. There are no temp files written to disk and no
external script invocations.

CLEANUP
The original .eml attachment is deleted from the ticket after analysis
to prevent analysts from accidentally opening a potentially malicious file.
═══════════════════════════════════════════════════════════════════════════════
#>

#endregion

Additional Resources