Public/ConvertTo-MailHeaderReport.ps1

function ConvertTo-MailHeaderReport {
    <#
    .SYNOPSIS
        Renders an analysis as a Markdown or plain-text report, for tickets and hand-overs.
 
    .DESCRIPTION
        Takes the object returned by Get-MailHeaderAnalysis and produces a compact report:
        message facts, authentication results, findings and the delivery chain as a table.
        Direction control characters stay visible as <U+...> so that the report cannot be
        used to smuggle them into a ticket.
 
    .PARAMETER Analysis
        The analysis object from Get-MailHeaderAnalysis. Accepts pipeline input.
 
    .PARAMETER Format
        Markdown (default) or Text.
 
    .EXAMPLE
        Get-MailHeaderAnalysis -Path .\message.eml | ConvertTo-MailHeaderReport
 
    .EXAMPLE
        Get-MailHeaderAnalysis -FromClipboard | ConvertTo-MailHeaderReport -Format Text | Set-Clipboard
 
    .OUTPUTS
        System.String
 
    .LINK
        https://rafaelpfister.ch/en/tools/header-analyzer
    #>

    [CmdletBinding(HelpUri = 'https://rafaelpfister.ch/en/tools/header-analyzer')]
    [OutputType([string])]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [PSTypeName('MailHeaderAnalyzer.Analysis')]
        $Analysis,

        [ValidateSet('Markdown', 'Text')]
        [string]$Format = 'Markdown'
    )

    process {
        $markdown = ($Format -eq 'Markdown')
        $lines = New-Object System.Collections.Generic.List[string]
        $h1 = { param($t) if ($markdown) { '# ' + $t } else { $t.ToUpperInvariant() } }
        $h2 = { param($t) if ($markdown) { '## ' + $t } else { $t } }
        $bullet = { param($t) if ($markdown) { '- ' + $t } else { ' ' + $t } }
        $safe = { param($t) if ($null -eq $t) { return '' }; Show-ControlCharacter -Text ([string]$t) }
        $stamp = { param($d) if ($null -eq $d) { return '-' }; $d.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') }
        $dur = { param($ts) if ($null -eq $ts) { return '-' }; Format-Duration -Seconds ([int64]$ts.TotalSeconds) }

        $lines.Add((& $h1 'Email header analysis'))
        $lines.Add('')
        if ($Analysis.Subject) { $lines.Add((& $bullet ('Subject: ' + (& $safe $Analysis.Subject)))) }
        if ($Analysis.From) { $lines.Add((& $bullet ('From: ' + (& $safe $Analysis.From.Display)))) }
        if ($Analysis.Date) { $lines.Add((& $bullet ('Date: ' + (& $stamp $Analysis.Date)))) }
        if ($Analysis.MessageId) { $lines.Add((& $bullet ('Message-ID: ' + (& $safe $Analysis.MessageId)))) }
        if ($Analysis.Source -and $Analysis.Source -notin @('Text', 'Clipboard')) { $lines.Add((& $bullet ('Source: ' + $Analysis.Source))) }

        $lines.Add('')
        $lines.Add((& $h2 'Authentication'))
        $lines.Add('')
        $any = $false
        foreach ($key in 'Spf', 'Dkim', 'Dmarc', 'Arc', 'CompAuth') {
            $value = $Analysis.$key
            if ($null -eq $value) { continue }
            $any = $true
            $detail = ''
            if ($key -eq 'CompAuth' -and $Analysis.CompAuthReason) {
                $detail = ' (reason={0}' -f $Analysis.CompAuthReason
                if ($Analysis.CompAuthReasonMeaning) { $detail += ': ' + $Analysis.CompAuthReasonMeaning }
                $detail += ')'
            }
            $lines.Add((& $bullet ('{0}: {1}{2}' -f $key.ToLowerInvariant(), $value, $detail)))
        }
        if (-not $any) { $lines.Add((& $bullet 'No Authentication-Results found.')) }
        switch ($Analysis.AuthTrust) {
            'Matched' { $lines.Add((& $bullet ('Verified by: {0} (appears in the delivery chain)' -f $Analysis.AuthServId))) }
            'Unmatched' { $lines.Add((& $bullet ('Not verifiable: results carry {0}, which does not appear in the delivery chain' -f $Analysis.AuthServId))) }
            'Absent' { $lines.Add((& $bullet 'Results without authserv-id (Microsoft 365 style)')) }
        }
        if ($Analysis.SpfAlignment) { $lines.Add((& $bullet ('DMARC alignment: SPF {0}, DKIM {1}' -f $Analysis.SpfAlignment, $(if ($Analysis.DkimAlignment) { $Analysis.DkimAlignment } else { '-' })))) }

        if ($Analysis.Findings.Count -gt 0) {
            $lines.Add('')
            $lines.Add((& $h2 'Findings'))
            $lines.Add('')
            foreach ($f in $Analysis.Findings) {
                $lines.Add((& $bullet ('[{0}] {1}: {2}' -f $f.Severity, $f.Code, (& $safe $f.Message))))
            }
        }

        if ($Analysis.Hops.Count -gt 0) {
            $lines.Add('')
            $title = 'Delivery chain'
            if ($null -ne $Analysis.TotalDuration) { $title += ' (total: {0})' -f (& $dur $Analysis.TotalDuration) }
            $lines.Add((& $h2 $title))
            $lines.Add('')
            if ($markdown) {
                $lines.Add('| # | From | By | Protocol | TLS | Time (UTC) | Delay |')
                $lines.Add('|---|---|---|---|---|---|---|')
            }
            foreach ($hop in $Analysis.Hops) {
                $fromParts = @()
                if ($hop.ReverseDns) { $fromParts += $hop.ReverseDns } elseif ($hop.FromHost) { $fromParts += $hop.FromHost }
                if ($hop.IPAddress) { $fromParts += ('({0})' -f $hop.IPAddress) }
                $fromText = $fromParts -join ' '
                if (-not $fromText) { $fromText = '-' }
                $byText = $hop.ByHost
                if (-not $byText) { $byText = '-' }
                $protoText = $hop.Protocol
                if (-not $protoText) { $protoText = '-' }
                $tlsText = $hop.TlsVersion
                if (-not $tlsText) { $tlsText = '-' }
                if ($markdown) {
                    $lines.Add(('| {0} | {1} | {2} | {3} | {4} | {5} | {6} |' -f $hop.Index, (& $safe $fromText), (& $safe $byText), (& $safe $protoText), $tlsText, (& $stamp $hop.Date), (& $dur $hop.Delay)))
                } else {
                    $delayText = ''
                    if ($null -ne $hop.Delay) { $delayText = ' (+{0})' -f (& $dur $hop.Delay) }
                    $lines.Add((' {0,2}. {1} -> {2} [{3}, {4}] {5}{6}' -f $hop.Index, (& $safe $fromText), (& $safe $byText), (& $safe $protoText), $tlsText, (& $stamp $hop.Date), $delayText))
                }
            }
        }

        if ($null -ne $Analysis.Exchange) {
            $x = $Analysis.Exchange
            $lines.Add('')
            $lines.Add((& $h2 'Exchange hybrid classification'))
            $lines.Add('')
            if ($x.Directionality) { $lines.Add((& $bullet ('MessageDirectionality: ' + $x.Directionality))) }
            if ($x.AuthAs) { $lines.Add((& $bullet ('AuthAs: ' + $x.AuthAs))) }
            if ($x.AuthMechanism) { $lines.Add((& $bullet ('AuthMechanism: ' + $x.AuthMechanism))) }
            if ($x.AuthSource) { $lines.Add((& $bullet ('AuthSource: ' + $x.AuthSource))) }
            if ($x.OriginatorOrg) { $lines.Add((& $bullet ('X-OriginatorOrg: ' + $x.OriginatorOrg))) }
            if ($x.CrossTenantFromEntity) { $lines.Add((& $bullet ('CrossTenant-FromEntityHeader: ' + $x.CrossTenantFromEntity))) }
            if ($x.CrossTenantId) { $lines.Add((& $bullet ('CrossTenant-Id: ' + $x.CrossTenantId))) }
        }

        if ($null -ne $Analysis.Spam) {
            $s = $Analysis.Spam
            $lines.Add('')
            $lines.Add((& $h2 'Spam filter'))
            $lines.Add('')
            if ($null -ne $s.Scl) { $lines.Add((& $bullet ('SCL: {0}{1}' -f $s.Scl, $(if ($s.SclMeaning) { ' (' + $s.SclMeaning + ')' } else { '' })))) }
            if ($null -ne $s.Bcl) { $lines.Add((& $bullet ('BCL: ' + $s.Bcl))) }
            if ($s.Category) { $lines.Add((& $bullet ('CAT: {0}{1}' -f $s.Category, $(if ($s.CategoryMeaning) { ' (' + $s.CategoryMeaning + ')' } else { '' })))) }
            if ($s.SpamFilterVerdict) { $lines.Add((& $bullet ('SFV: {0}{1}' -f $s.SpamFilterVerdict, $(if ($s.SpamFilterMeaning) { ' (' + $s.SpamFilterMeaning + ')' } else { '' })))) }
            if ($s.SpamAssassinScore) { $lines.Add((& $bullet ('SpamAssassin score: {0}; tests: {1}' -f $s.SpamAssassinScore, ($s.SpamAssassinTests -join ', ')))) }
            if ($s.RspamdSymbols.Count -gt 0) { $lines.Add((& $bullet ('Rspamd: ' + (($s.RspamdSymbols | ForEach-Object { '{0}({1})' -f $_.Name, $_.Score }) -join ' ')))) }
        }

        $version = '?'
        $module = Get-Module -Name MailHeaderAnalyzer
        if ($module) { $version = $module.Version.ToString() }
        $lines.Add('')
        if ($markdown) { $lines.Add(('> Generated by MailHeaderAnalyzer {0} (offline analysis)' -f $version)) }
        else { $lines.Add(('Generated by MailHeaderAnalyzer {0} (offline analysis)' -f $version)) }

        return ($lines -join "`n")
    }
}