Public/Exchange/MessageTrace/Get-MessageTraceInfo.ps1

<#
    .SYNOPSIS
    Retrieves message trace information based on specified criteria.
 
    .DESCRIPTION
    This function retrieves message trace information from Exchange Online based on the specified criteria. It supports filtering by sender address, recipient address, start date, end date, connector name, transport rule name, and using connector flag.
 
    .PARAMETER SenderAddress
    Specifies the sender address to filter the message trace. This parameter accepts a string or an array of strings. Wildcards (*) can be used to match multiple sender addresses.
 
    .PARAMETER RecipientAddress
    Specifies the recipient address to filter the message trace. This parameter accepts a string or an array of strings. Wildcards (*) can be used to match multiple recipient addresses.
 
    .PARAMETER StartDate
    Specifies the start date to filter the message trace. This parameter accepts a DateTime object.
 
    .PARAMETER EndDate
    Specifies the end date to filter the message trace. This parameter accepts a DateTime object.
 
    .PARAMETER ByConnectorName
    Specifies the connector name to filter the message trace. This parameter accepts a string.
 
    .PARAMETER ByTransportRuleName
    Specifies the transport rule name to filter the message trace. This parameter accepts a string.
 
    .PARAMETER UsingConnector
    Specifies whether to filter the message trace using a connector. This parameter is a switch parameter.
 
    .EXAMPLE
    Get-MessageTraceInfo -SenderAddress "john.doe@example.com" -RecipientAddress "jane.doe@example.com" -StartDate (Get-Date).AddDays(-7) -EndDate (Get-Date) -ByConnectorName "OutboundConnector" -UsingConnector
     
    Retrieves message trace information for messages sent by "john.doe@example.com" to "jane.doe@example.com" within the last 7 days, using the "OutboundConnector" connector.
 
    .EXAMPLE
    Get-MessageTraceInfo -RecipientAddress "jane.doe@example.com" -StartDate (Get-Date).AddDays(-30) -EndDate (Get-Date) -ByTransportRuleName "Confidential" -UsingConnector
     
    Retrieves message trace information for messages received by "jane.doe@example.com" within the last 30 days, filtered by the "Confidential" transport rule, using a connector.
 
    .LINK
    https://ps365.clidsys.com/docs/commands/Get-MessageTraceInfo
 
    .NOTES
    This function requires the Exchange Online PowerShell module to be installed and connected to an Exchange Online organization.
 
    For some event, it will fail to get messagetracedetail
#>


function Get-MessageTraceInfo {
    param(
        [Parameter(Mandatory = $false)]
        [String[]] $SenderAddress,
        [Parameter(Mandatory = $false)]
        [String[]] $RecipientAddress,
        [Parameter(Mandatory = $false)]
        [datetime] $StartDate,
        [Parameter(Mandatory = $false)]
        [datetime] $EndDate,
        [Parameter(Mandatory = $false)]
        [String] $ByConnectorName,
        [Parameter(Mandatory = $false)]
        [String]$ByTransportRuleName,
        [Parameter(Mandatory = $false)]
        [Switch] $UsingConnector
    )

    [System.Collections.Generic.List[PSObject]]$messagesInfo = @()
    [System.Collections.Generic.List[PSObject]]$messagesList = @()

    # PageSize maximum default = 1000; Max PageSize = 5000
    # There isn't really a reason to decrease this in this instance.
    <#
    not present with Get-MessageTraceV2
    $messageTraceParams = @{
        PageSize = 5000
    }
    #>


    # Always initialize so later $messageTraceParams.Add(...) calls work even when
    # neither RecipientAddress nor SenderAddress was supplied.
    $messageTraceParams = @{}
    if ($RecipientAddress) {
        $messageTraceParams['RecipientAddress'] = $RecipientAddress
    }
    if ($SenderAddress) {
        $messageTraceParams['SenderAddress'] = $SenderAddress
    }

    # convert to UTC because Exchange Online used UTC time
    if ($StartDate) {
        $messageTraceParams.Add('StartDate', $StartDate.ToUniversalTime())
        
        if ($EndDate) {
            $messageTraceParams.Add('EndDate', $EndDate.ToUniversalTime())
        }
        else {
            $messageTraceParams.Add('EndDate', $((Get-Date).ToUniversalTime()))
        }
    }

    <#
    Not present with Get-MessageTraceV2
    $messageTraceParams.Add('Page', 1)
    $maxPage = 1000
     
     
    $page = 1
    $maxPage = 1000
    $pageSize = 5000 # Max pagesize is 5000. There isn't really a reason to decrease this in this instance.
    #>


    $messages = Get-MessageTraceV2 @messageTraceParams | Select-Object MessageId, Received, SenderAddress, RecipientAddress, Subject, Status, ToIP, FromIP, Size, MessageTraceId, StartDate, EndDate
    
    foreach ($message in $messages) {
        $messagesList.Add($message)
    }

    Write-Host -ForegroundColor Cyan "Found $($messagesList.Count) messages (in total, without the filter)"
    
    $oldProgressPreference = $ProgressPreference

    $ProgressPreference = 'SilentlyContinue'

    foreach ($message in $messagesList) {
        # Ignore Journaling events if exist
        
        if ($UsingConnector.IsPresent) {
            $traceDetail = $message | Get-MessageTraceDetailV2 | Where-Object { $_.Action -eq 'RouteMessageUsingConnector' -and $_.Event -ne 'Journal' }
        }
        elseif ($ByTransportRuleName) {
            $traceDetail = $message | Get-MessageTraceDetailV2 | Where-Object { $_.Detail -like "*$ByTransportRuleName*" }
        }
        elseif ($ByConnectorName) {
            #$traceDetail = $message | Get-MessageTraceDetailV2 -Event RECEIVE | Where-Object { $_.Data -like "*S:InboundConnectorData=Name=$ByConnectorName*" }
            $traceDetail = $message | Get-MessageTraceDetailV2 | Where-Object { $_.Data -like "*S:Microsoft.Exchange.Hygiene.TenantOutboundConnectorCustomData=Name=$ByConnectorName*" }
        }
        else {
            #$traceDetail = $message | Get-MessageTraceDetailV2 -Event RECEIVE | Where-Object { $_.Event -ne 'Journal' }
            # Event: -Event RECEIVE, SEND, FAIL, DELIVER, EXPAND, TRANSFER, DEFER, DROP
            # event list: https://learn.microsoft.com/en-us/exchange/mail-flow/transport-logs/message-tracking?view=exchserver-2019#event-types-in-the-message-tracking-log
            
            # $message is always a single object inside this foreach, so the old
            # "$message.Count -eq 1" test was always true and the Journal filter
            # below never ran. Apply it directly and take the last matching event.
            $traceDetail = ($message | Get-MessageTraceDetailV2 | Where-Object { $_.Event -ne 'Journal' })[-1]
        }

        # redirect has no messagetracedetails in event receive
        if ($null -ne $traceDetail) {

            # Get-MessageTraceDetailV2 + Where-Object can return multiple rows. Casting an array to
            # [XML] fails with "This document already has a 'DocumentElement' node", so we iterate
            # one row at a time and emit one object per detail record.
            foreach ($detail in @($traceDetail)) {
                $hashTable = @{}

                $XMLDoc = $null
                try {
                    $XMLDoc = [XML]$detail.Data
                }
                catch {
                    Write-Verbose "Could not parse trace detail Data as XML: $($_.Exception.Message)"
                }

                $MEPNodes = $null
                if ($XMLDoc) {
                    try {
                        $MEPNodes = $XMLDoc.GetElementsByTagName('MEP')
                    }
                    catch {
                        Write-Verbose "Could not get MEP nodes: $($_.Exception.Message)"
                    }
                }

                if ($MEPNodes) {
                    for ($nodeval = 0; $nodeval -lt $MEPNodes.Count; $nodeval++) {
                        $key = $MEPNodes[$nodeval].Attributes[0].Value.ToString()
                        $value = $MEPNodes[$nodeval].Attributes[1].Value.ToString()
                        # Use [hashtable] indexer assignment instead of .Add() to tolerate duplicate keys.
                        $hashTable[$key] = $value
                    }
                }

                # Helper: extract a CustomData S:<Key>=<Value> fragment regardless of ordering.
                $customData = $hashTable['customdata']
                $extractFromCustomData = {
                    param([string]$Key)
                    if ([string]::IsNullOrWhiteSpace($customData)) { return $null }
                    $match = $customData -split ";'" -split ';' | Where-Object { $_ -match "^S:$Key=" } | Select-Object -First 1
                    if ($match) { return ($match -replace "^S:$Key=", '').Trim() }
                    return $null
                }

                # Mail client / user-agent best-effort extraction. The exact field that carries the
                # client name varies by event (SUBMIT, RECEIVE, DELIVER) and source (SMTP, OWA, mobile),
                # so we surface every known candidate so the user can pick whichever is populated.
                $userAgent         = & $extractFromCustomData 'UserAgent'
                $messageSourceName = & $extractFromCustomData 'MessageSourceName'
                $clientName        = & $extractFromCustomData 'ClientName'

                $object = [PSCustomObject] [ordered]@{
                    SenderAddress        = $message.SenderAddress
                    RecipientAddress     = $message.RecipientAddress
                    ReturnPath           = $hashTable['ReturnPath']
                    Subject              = $message.Subject
                    Detail               = $detail.Detail
                    Status               = $message.Status
                    'Received(UTC)'      = $message.Received
                    FromIP               = $message.FromIP
                    ToIP                 = $message.ToIP
                    ClientIP             = $hashTable['ClientIP']
                    UserAgent            = $userAgent
                    MessageSourceName    = $messageSourceName
                    ClientName           = $clientName
                    DeliveryPriority     = $hashTable['DeliveryPriority']
                    # CustomData is parsed below
                    #CustomData = $hashTable['CustomData']
                    InboundConnectorData = (& $extractFromCustomData 'InboundConnectorData') -replace '^Name=', ''
                    ConnectorType        = ($customData -split ";'" -split ';' -match 'ConnectorType' -split 'ConnectorType=')[1]
                    TLSVersion           = & $extractFromCustomData 'tlsversion'
                    TLSCipher            = & $extractFromCustomData 'tlscipher'
                    OriginOrg            = & $extractFromCustomData 'Oorg'
                    MessageID            = $detail.MessageId
                    MessageTraceID       = $detail.MessageTraceId
                }

                $messagesInfo.Add($object)
            }
        }
    }

    $ProgressPreference = $oldProgressPreference
    
    return $messagesInfo
}