Modules/MDOP2.Common.psm1

Set-StrictMode -Version Latest

function ConvertTo-MDOP2ODataLiteral {
    <#
    .SYNOPSIS
    Returns a value as a quoted OData string literal. Single quotes are doubled so user input can't break out of the literal.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)][AllowEmptyString()][string]$Value
    )
    if ($Value -match '[\x00-\x1F]') {
        throw 'Value contains control characters and cannot be used in an OData filter.'
    }
    "'" + $Value.Replace("'", "''") + "'"
}

function Get-MDOP2RetryDelay {
    <#
    .SYNOPSIS
    Works out how many seconds to wait before retrying a throttled or failed request.
    Uses Retry-After when the service sends it, otherwise exponential backoff capped at MaxSeconds.
    #>

    [CmdletBinding()]
    [OutputType([int])]
    param(
        [Parameter(Mandatory)][int]$Attempt,
        [string]$RetryAfter,
        [int]$BaseSeconds = 2,
        [int]$MaxSeconds = 120
    )
    if ($RetryAfter) {
        $seconds = 0
        if ([int]::TryParse($RetryAfter.Trim(), [ref]$seconds) -and $seconds -ge 0) {
            return [Math]::Min([Math]::Max($seconds, 1), $MaxSeconds)
        }
        $date = [DateTimeOffset]::MinValue
        if ([DateTimeOffset]::TryParse($RetryAfter.Trim(), [ref]$date)) {
            $delta = [int][Math]::Ceiling(($date - [DateTimeOffset]::UtcNow).TotalSeconds)
            return [Math]::Min([Math]::Max($delta, 1), $MaxSeconds)
        }
    }
    $delay = $BaseSeconds * [Math]::Pow(2, [Math]::Max($Attempt - 1, 0))
    [int][Math]::Min($delay, $MaxSeconds)
}

function Test-MDOP2RetryableStatus {
    [CmdletBinding()]
    [OutputType([bool])]
    param([Parameter(Mandatory)][int]$StatusCode)
    $StatusCode -in 429, 500, 502, 503, 504
}

function Get-MDOP2DateRange {
    <#
    .SYNOPSIS
    Turns a range preset into a UTC start and end. 90d starts a few minutes late because message trace rejects anything older than 90 days.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][ValidateSet('1d', '7d', '30d', '90d')][string]$Range,
        [datetime]$Now = [datetime]::UtcNow
    )
    $end = $Now.ToUniversalTime()
    $start = $end.AddDays(-[int]$Range.TrimEnd('d'))
    if ($Range -eq '90d') { $start = $start.AddMinutes(10) }
    [pscustomobject]@{ Start = $start; End = $end }
}

function Split-MDOP2DateRange {
    <#
    .SYNOPSIS
    Splits a date range into windows of at most MaxDays, newest first. Get-MessageTraceV2 accepts 10 days per query.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][datetime]$Start,
        [Parameter(Mandatory)][datetime]$End,
        [ValidateRange(1, 10)][int]$MaxDays = 10
    )
    if ($End -le $Start) { throw 'End must be after start.' }
    $windowEnd = $End
    while ($windowEnd -gt $Start) {
        $windowStart = $windowEnd.AddDays(-$MaxDays)
        if ($windowStart -lt $Start) { $windowStart = $Start }
        [pscustomobject]@{ Start = $windowStart; End = $windowEnd }
        $windowEnd = $windowStart
    }
}

function ConvertTo-MDOP2AddressFilter {
    <#
    .SYNOPSIS
    Parses sender or recipient input. Full addresses can go to message trace; domains (contoso.com or @contoso.com) are matched locally
    because message trace has no wildcards.
    #>

    [CmdletBinding()]
    param([AllowNull()][AllowEmptyCollection()][string[]]$Value)
    $addresses = [Collections.Generic.List[string]]::new()
    $domains = [Collections.Generic.List[string]]::new()
    foreach ($raw in @($Value)) {
        if ([string]::IsNullOrWhiteSpace($raw)) { continue }
        $v = $raw.Trim().ToLowerInvariant()
        if ($v -match '^[^\s@<>"*?]+@[a-z0-9.-]+\.[a-z]{2,}$') { $addresses.Add($v) }
        elseif ($v -match '^@?([a-z0-9-]+(\.[a-z0-9-]+)*\.[a-z]{2,})$') { $domains.Add($Matches[1]) }
        else { throw "'$raw' is not an email address or domain." }
    }
    [pscustomobject]@{
        Addresses = $addresses.ToArray()
        Domains   = $domains.ToArray()
        IsEmpty   = ($addresses.Count + $domains.Count) -eq 0
        # Message trace can only filter server side when every value is a full address
        ServerSide = $domains.Count -eq 0 -and $addresses.Count -gt 0
    }
}

function Test-MDOP2AddressMatch {
    <#
    .SYNOPSIS
    True when an address matches a filter from ConvertTo-MDOP2AddressFilter. An empty filter matches everything.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [AllowNull()][AllowEmptyString()][string]$Address,
        [Parameter(Mandatory)][object]$Filter
    )
    if ($Filter.IsEmpty) { return $true }
    if ([string]::IsNullOrEmpty($Address)) { return $false }
    $a = $Address.ToLowerInvariant()
    if ($a -in $Filter.Addresses) { return $true }
    foreach ($d in $Filter.Domains) {
        if ($a.EndsWith("@$d")) { return $true }
    }
    $false
}

function Get-MDOP2ThrottleDelay {
    <#
    .SYNOPSIS
    Seconds to wait before the next query so that no more than Limit queries run in any WindowSeconds period.
    #>

    [CmdletBinding()]
    [OutputType([int])]
    param(
        [AllowEmptyCollection()][datetime[]]$History = @(),
        [datetime]$Now = [datetime]::UtcNow,
        [int]$Limit = 100,
        [int]$WindowSeconds = 300
    )
    $recent = @($History | Where-Object { ($Now - $_).TotalSeconds -lt $WindowSeconds } | Sort-Object)
    if ($recent.Count -lt $Limit) { return 0 }
    $oldest = $recent[$recent.Count - $Limit]
    [int][Math]::Max(1, [Math]::Ceiling($WindowSeconds - ($Now - $oldest).TotalSeconds))
}

function Group-MDOP2TraceResult {
    <#
    .SYNOPSIS
    Groups message trace rows by Message-ID into one entry per message, with its recipients and their delivery status.
    #>

    [CmdletBinding()]
    param([AllowNull()][AllowEmptyCollection()][object[]]$Rows)
    $groups = [ordered]@{}
    foreach ($r in @($Rows)) {
        if ($null -eq $r) { continue }
        $id = [string]$r.MessageId
        if (-not $id) { $id = "trace:$($r.MessageTraceId)" }
        if (-not $groups.Contains($id)) {
            $groups[$id] = [pscustomobject]@{
                Key               = $id
                InternetMessageId = [string]$r.MessageId
                Subject           = [string]$r.Subject
                SenderAddress     = [string]$r.SenderAddress
                FromIP            = $(if ($r.PSObject.Properties['FromIP']) { [string]$r.FromIP } else { '' })
                FirstReceived     = $r.Received
                LastReceived      = $r.Received
                RecipientCount    = 0
                Recipients        = [Collections.Generic.List[object]]::new()
                StatusCounts      = [ordered]@{}
                Index             = @{}
            }
        }
        $g = $groups[$id]
        if ($r.Received -lt $g.FirstReceived) { $g.FirstReceived = $r.Received }
        if ($r.Received -gt $g.LastReceived) { $g.LastReceived = $r.Received }
        $addr = ([string]$r.RecipientAddress).ToLowerInvariant()
        if ($g.Index.ContainsKey($addr)) {
            # Same recipient seen more than once (retries, forwarding): keep the newest row
            $existing = $g.Index[$addr]
            if ($r.Received -gt $existing.Received) {
                $existing.Received = $r.Received
                $existing.Status = [string]$r.Status
                $existing.MessageTraceId = $r.MessageTraceId
            }
            continue
        }
        $rcpt = [pscustomobject]@{
            Address        = $addr
            Status         = [string]$r.Status
            Received       = $r.Received
            MessageTraceId = $r.MessageTraceId
        }
        $g.Index[$addr] = $rcpt
        $g.Recipients.Add($rcpt)
    }
    foreach ($g in $groups.Values) {
        $g.RecipientCount = $g.Recipients.Count
        foreach ($rc in $g.Recipients) {
            $g.StatusCounts[$rc.Status] = 1 + ($g.StatusCounts[$rc.Status] ?? 0)
        }
        $g.PSObject.Properties.Remove('Index')
        $g.Recipients = $g.Recipients.ToArray()
        $g
    }
}

function Get-MDOP2ResolveSkipReason {
    <#
    .SYNOPSIS
    Says why a recipient can't be searched with Graph, or returns nothing when it can.
    #>

    [CmdletBinding()]
    param(
        [AllowNull()][AllowEmptyString()][string]$TraceStatus,
        [AllowNull()][AllowEmptyString()][string]$RecipientTypeDetails
    )
    if ($TraceStatus -in 'Failed', 'Quarantined', 'Expanded') { return "Trace status $TraceStatus, not in a mailbox" }
    if (-not $RecipientTypeDetails) { return 'Not a recipient in this tenant' }
    if ($RecipientTypeDetails -eq 'GroupMailbox') { return 'Microsoft 365 group mailbox, not supported' }
    if ($RecipientTypeDetails -notin 'UserMailbox', 'SharedMailbox', 'RoomMailbox', 'EquipmentMailbox', 'SchedulingMailbox') {
        return "Recipient type $RecipientTypeDetails has no mailbox in this tenant"
    }
}

function Get-MDOP2MailboxErrorState {
    <#
    .SYNOPSIS
    Maps a Graph error on a mailbox call to a short state the UI can show.
    #>

    [CmdletBinding()]
    param([int]$StatusCode, [string]$ErrorCode)
    switch ($StatusCode) {
        403 { return 'AccessDenied' }
        404 {
            if ($ErrorCode -in 'MailboxNotEnabledForRESTAPI', 'MailboxNotSupportedForRESTAPI', 'ResourceNotFound', 'ErrorInvalidUser') { return 'NoMailbox' }
            return 'NotFound'
        }
        default { return 'Error' }
    }
}

$script:ActionFolders = [ordered]@{
    MoveToInbox        = 'inbox'
    MoveToArchive      = 'archive'
    MoveToJunk         = 'junkemail'
    MoveToDeletedItems = 'deleteditems'
    SoftDelete         = 'recoverableitemsdeletions'
}

function Get-MDOP2ActionFolder {
    <#
    .SYNOPSIS
    Returns the well-known Graph folder an action moves mail to.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(Mandatory)][ValidateSet('MoveToInbox', 'MoveToArchive', 'MoveToJunk', 'MoveToDeletedItems', 'SoftDelete')][string]$Action)
    $script:ActionFolders[$Action]
}

function Get-MDOP2SameMailbox {
    <#
    .SYNOPSIS
    Message trace can list an alias (status Resolved) and the primary address of one mailbox as separate recipients of one
    message. Returns one entry per row: the address of the row that stands for the same mailbox, or $null when the row is
    that one. The delivered row is kept over the Resolved alias row.
    #>

    [CmdletBinding()]
    param([AllowEmptyCollection()][object[]]$Row = @())
    $rows = @($Row)
    $keep = @{}
    foreach ($alias in $false, $true) {
        foreach ($r in $rows) {
            if (-not $r -or -not $r.UserId -or $keep.ContainsKey([string]$r.UserId)) { continue }
            $isAlias = $r.PSObject.Properties['TraceStatus'] -and $r.TraceStatus -eq 'Resolved'
            if ($isAlias -eq $alias) { $keep[[string]$r.UserId] = [string]$r.Address }
        }
    }
    , @(foreach ($r in $rows) {
            if ($r -and $r.UserId -and $keep[[string]$r.UserId] -ne [string]$r.Address) { $keep[[string]$r.UserId] } else { $null }
        })
}

function New-MDOP2ActionPlan {
    <#
    .SYNOPSIS
    Turns resolve results (one row per recipient, from Resolve-MDOP2MessageGroup) into one plan item per message copy.
    Recipients where the message wasn't found or can't be searched become skipped items, so they still show up in the result.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$InternetMessageId,
        [AllowEmptyCollection()][object[]]$Resolved = @(),
        [Parameter(Mandatory)][ValidateSet('MoveToInbox', 'MoveToArchive', 'MoveToJunk', 'MoveToDeletedItems', 'SoftDelete')][string]$Action,
        # Allow moving copies out of Recoverable Items (restore). Only set when the admin chose a restore.
        [switch]$AllowRestore
    )
    $destination = Get-MDOP2ActionFolder -Action $Action
    # Each mailbox copy is planned once, also when an alias and the primary address both received it
    $rows = @($Resolved)
    $sameAs = Get-MDOP2SameMailbox -Row $rows
    for ($i = 0; $i -lt $rows.Count; $i++) {
        $r = $rows[$i]
        if ($null -eq $r) { continue }
        $base = [ordered]@{
            InternetMessageId = $InternetMessageId
            Address           = [string]$r.Address
            UserId            = [string]$r.UserId
            Action            = $Action
            Destination       = $destination
            AllowRestore      = [bool]$AllowRestore
        }
        if ($r.State -eq 'Found' -and @($r.Locations).Count) {
            foreach ($loc in $r.Locations) {
                $item = [ordered]@{} + $base
                $item.GraphMessageId = [string]$loc.GraphMessageId
                $item.FolderId = [string]$loc.FolderId
                $item.FolderName = [string]$loc.FolderName
                $item.Plan = if ($sameAs[$i]) { 'Skip' } else { 'Move' }
                $item.Reason = if ($sameAs[$i]) { "Same mailbox as $($sameAs[$i])" } else { $null }
                [pscustomobject]$item
            }
        }
        else {
            $item = [ordered]@{} + $base
            $item.GraphMessageId = $null
            $item.FolderId = $null
            $item.FolderName = $null
            $item.Plan = 'Skip'
            $item.Reason = if ($r.Detail) { "$($r.State): $($r.Detail)" } else { [string]$r.State }
            [pscustomobject]$item
        }
    }
}

function Get-MDOP2MoveDecision {
    <#
    .SYNOPSIS
    Decides whether a message copy should move. Folder IDs are compared, not names, because names are localised.
    Copies already in Recoverable Items are only moved back out when the admin chose a restore (-AllowRestore).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$CurrentFolderId,
        [Parameter(Mandatory)][string]$TargetFolderId,
        [string]$RecoverableItemsFolderId,
        [Parameter(Mandatory)][string]$Action,
        [switch]$AllowRestore
    )
    if ($CurrentFolderId -eq $TargetFolderId) {
        return [pscustomobject]@{ Move = $false; Result = 'AlreadyThere'; Reason = 'Already in the target folder' }
    }
    if ($RecoverableItemsFolderId -and $CurrentFolderId -eq $RecoverableItemsFolderId -and $Action -ne 'SoftDelete' -and -not $AllowRestore) {
        return [pscustomobject]@{ Move = $false; Result = 'Skipped'; Reason = 'Already soft deleted; not moved back out of Recoverable Items' }
    }
    [pscustomobject]@{ Move = $true; Result = $null; Reason = $null }
}

function Get-MDOP2ActionSummary {
    <#
    .SYNOPSIS
    Counts what an action plan will touch, for the confirmation prompt.
    #>

    [CmdletBinding()]
    param([AllowEmptyCollection()][object[]]$Plan = @())
    $move = @($Plan | Where-Object { $_ -and $_.Plan -eq 'Move' })
    [pscustomobject]@{
        Messages  = @($move | ForEach-Object InternetMessageId | Sort-Object -Unique).Count
        Mailboxes = @($move | ForEach-Object UserId | Sort-Object -Unique).Count
        Copies    = $move.Count
        Skipped   = @($Plan | Where-Object { $_ -and $_.Plan -eq 'Skip' }).Count
    }
}

$script:AuditBlockedKey = '(?i)secret|token|password|credential|authorization|body|content|mime'

function Remove-MDOP2SensitiveData {
    param([AllowNull()][object]$Value, [int]$Depth = 0)
    if ($null -eq $Value -or $Depth -gt 8) { return $Value }
    if ($Value -is [string] -or $Value -is [ValueType]) { return $Value }
    if ($Value -is [Security.SecureString]) { return '[removed]' }
    if ($Value -is [Collections.IDictionary]) {
        $out = [ordered]@{}
        foreach ($k in $Value.Keys) {
            if ([string]$k -match $script:AuditBlockedKey) { continue }
            $out[[string]$k] = Remove-MDOP2SensitiveData -Value $Value[$k] -Depth ($Depth + 1)
        }
        return $out
    }
    if ($Value -is [Collections.IEnumerable]) {
        return , @(foreach ($v in $Value) { Remove-MDOP2SensitiveData -Value $v -Depth ($Depth + 1) })
    }
    $out = [ordered]@{}
    foreach ($p in $Value.PSObject.Properties) {
        if ($p.Name -match $script:AuditBlockedKey) { continue }
        $out[$p.Name] = Remove-MDOP2SensitiveData -Value $p.Value -Depth ($Depth + 1)
    }
    $out
}

function ConvertTo-MDOP2AuditLine {
    <#
    .SYNOPSIS
    Serialises an audit event to one JSON line. Keys that could hold secrets, tokens or message content are dropped at any depth.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(Mandatory)][Collections.IDictionary]$Event)
    $clean = Remove-MDOP2SensitiveData -Value $Event
    # -Compress escapes line breaks inside strings, so one event is always one line
    ConvertTo-Json -InputObject $clean -Depth 10 -Compress
}

#region Message analysis (detail page)

function Get-MDOP2AddressDomain {
    <#
    .SYNOPSIS
    Returns the lower case domain of an address (angle brackets allowed), or an empty string.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyString()][string]$Address)
    if ($Address -match '@([^@\s<>]+?)>?\s*$') { return $Matches[1].ToLowerInvariant().TrimEnd('.') }
    ''
}

function Test-MDOP2DomainAligned {
    <#
    .SYNOPSIS
    Relaxed alignment as in DMARC: the domains are equal or one is a subdomain of the other.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param([AllowEmptyString()][string]$A, [AllowEmptyString()][string]$B)
    if (-not $A -or -not $B) { return $true }
    $A = $A.ToLowerInvariant(); $B = $B.ToLowerInvariant()
    $A -eq $B -or $A.EndsWith(".$B") -or $B.EndsWith(".$A")
}

function Get-MDOP2HeaderValue {
    <#
    .SYNOPSIS
    Returns every value of a header (case-insensitive name) from Graph internetMessageHeaders, in message order.
    #>

    [CmdletBinding()]
    param([AllowNull()][AllowEmptyCollection()][object[]]$Headers, [Parameter(Mandatory)][string]$Name)
    , [string[]]@(foreach ($h in @($Headers)) { if ($h -and [string]$h.name -ieq $Name) { [string]$h.value } })
}

function ConvertFrom-MDOP2AuthenticationResult {
    <#
    .SYNOPSIS
    Parses SPF, DKIM, DMARC and CompAuth from Authentication-Results headers. Uses the header Exchange Online
    added (the one with compauth), otherwise the topmost one.
    #>

    [CmdletBinding()]
    param([AllowNull()][AllowEmptyCollection()][string[]]$Value)
    $values = @($Value | Where-Object { $_ })
    $out = [ordered]@{ spf = $null; dkim = $null; dmarc = $null; compauth = $null; raw = $null }
    if (-not $values.Count) { return [pscustomobject]$out }
    $pick = @($values | Where-Object { $_ -match '(?i)\bcompauth=' } | Select-Object -First 1)
    $raw = if ($pick.Count) { $pick[0] } else { $values[0] }
    $out.raw = $raw
    $flat = ($raw -replace '\s+', ' ').Trim()
    foreach ($part in $flat -split ';') {
        $part = $part.Trim()
        if ($part -notmatch '^(?i)(spf|dkim|dmarc|compauth)\s*=\s*([A-Za-z]+)\s*(.*)$') { continue }
        $method = $Matches[1].ToLowerInvariant()
        $entry = [pscustomobject]@{ result = $Matches[2].ToLowerInvariant(); detail = $Matches[3].Trim() }
        if ($null -eq $out[$method]) { $out[$method] = $entry }
        elseif ($method -eq 'dkim') {
            # Several signatures: pass if any passed, and keep every detail
            $cur = $out[$method]
            $result = if ($cur.result -eq 'pass' -or $entry.result -eq 'pass') { 'pass' } else { $cur.result }
            $out[$method] = [pscustomobject]@{ result = $result; detail = "$($cur.detail); $($entry.result) $($entry.detail)".Trim(' ', ';') }
        }
    }
    [pscustomobject]$out
}

function ConvertFrom-MDOP2AntispamReport {
    <#
    .SYNOPSIS
    Parses X-Forefront-Antispam-Report (CIP:1.2.3.4;CTRY:US;SCL:1;SFV:NSPM;...) into a dictionary with upper case keys.
    #>

    [CmdletBinding()]
    param([AllowNull()][AllowEmptyString()][string]$Value)
    $out = [ordered]@{}
    if (-not $Value) { return $out }
    foreach ($part in ($Value -replace '\s+', ' ') -split ';') {
        $i = $part.IndexOf(':')
        if ($i -lt 1) { continue }
        $k = $part.Substring(0, $i).Trim().ToUpperInvariant()
        if (-not $out.Contains($k)) { $out[$k] = $part.Substring($i + 1).Trim() }
    }
    $out
}

function ConvertFrom-MDOP2MailDate {
    <#
    .SYNOPSIS
    Parses an RFC 5322 date such as "Mon, 3 Nov 2026 10:11:12 +0100" to UTC. Returns nothing when it can't.
    #>

    [CmdletBinding()]
    param([AllowNull()][AllowEmptyString()][string]$Value)
    if (-not $Value) { return }
    if ($Value -notmatch '(\d{1,2})\s+([A-Za-z]{3})[a-z]*\s+(\d{4})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?\s*([+-]\d{4})?') { return }
    $months = @{ jan = 1; feb = 2; mar = 3; apr = 4; may = 5; jun = 6; jul = 7; aug = 8; sep = 9; oct = 10; nov = 11; dec = 12 }
    $day = [int]$Matches[1]; $mon = $months[$Matches[2].ToLowerInvariant()]; $year = [int]$Matches[3]
    $hour = [int]$Matches[4]; $min = [int]$Matches[5]
    $sec = if ($Matches[6]) { [int]$Matches[6] } else { 0 }
    $zone = $Matches[7]
    if (-not $mon) { return }
    $offset = [timespan]::Zero
    if ($zone) {
        $offset = [timespan]::new([int]$zone.Substring(1, 2), [int]$zone.Substring(3, 2), 0)
        if ($zone[0] -eq '-') { $offset = $offset.Negate() }
    }
    try { [datetimeoffset]::new($year, $mon, $day, $hour, $min, $sec, $offset).UtcDateTime }
    catch { return }
}

function ConvertFrom-MDOP2ReceivedHeader {
    <#
    .SYNOPSIS
    Parses Received headers (topmost first, as in the message) into hops in the order the message travelled,
    with the delay since the previous hop.
    #>

    [CmdletBinding()]
    param([AllowNull()][AllowEmptyCollection()][string[]]$Value)
    $list = [string[]]@($Value | Where-Object { $_ })
    [array]::Reverse($list)
    $prev = $null
    $hop = 0
    , @(foreach ($v in $list) {
        $hop++
        $flat = ($v -replace '\s+', ' ').Trim()
        $semi = $flat.LastIndexOf(';')
        $main = if ($semi -ge 0) { $flat.Substring(0, $semi) } else { $flat }
        $date = if ($semi -ge 0) { ConvertFrom-MDOP2MailDate $flat.Substring($semi + 1) } else { $null }
        $from = if ($main -match '(?i)(?:^|\s)from\s+(.+?)(?=\s+by\s|\s+with\s|$)') { $Matches[1].Trim() } else { '' }
        $by = if ($main -match '(?i)(?:^|\s)by\s+(.+?)(?=\s+with\s|\s+id\s|\s+via\s|\s+for\s|$)') { $Matches[1].Trim() } else { '' }
        $with = if ($main -match '(?i)(?:^|\s)with\s+(\S+)') { $Matches[1].Trim() } else { '' }
        $delay = if ($date -and $prev) { [int][Math]::Round(($date - $prev).TotalSeconds) } else { $null }
        if ($date) { $prev = $date }
        [pscustomobject]@{
            Hop          = $hop
            From         = $from
            By           = $by
            With         = $with
            Date         = $date
            DelaySeconds = $delay
            Raw          = $flat
        }
    })
}

function Get-MDOP2SuspicionFlag {
    <#
    .SYNOPSIS
    Simple checks for the detail page: sender mismatches, authentication failures and a display name that shows
    a different address. Returns items with Severity (High, Medium, Info) and Text.
    #>

    [CmdletBinding()]
    param(
        [AllowNull()][AllowEmptyString()][string]$FromAddress,
        [AllowNull()][AllowEmptyString()][string]$FromName,
        [AllowNull()][AllowEmptyString()][string]$ReturnPath,
        [AllowNull()][AllowEmptyCollection()][string[]]$ReplyTo,
        [AllowNull()][object]$Authentication
    )
    $fromDomain = Get-MDOP2AddressDomain $FromAddress
    $flags = [Collections.Generic.List[object]]::new()
    $add = { param($sev, $text) $flags.Add([pscustomobject]@{ Severity = $sev; Text = $text }) }

    $rpDomain = Get-MDOP2AddressDomain $ReturnPath
    if ($rpDomain -and $fromDomain -and -not (Test-MDOP2DomainAligned $rpDomain $fromDomain)) {
        & $add 'Medium' "Return-Path domain ($rpDomain) differs from the From domain ($fromDomain)."
    }
    foreach ($r in @($ReplyTo | Where-Object { $_ })) {
        $d = Get-MDOP2AddressDomain $r
        if ($d -and $fromDomain -and -not (Test-MDOP2DomainAligned $d $fromDomain)) {
            & $add 'Medium' "Reply-To ($r) is in a different domain than From ($fromDomain)."
        }
    }
    if ($FromName -and $FromName -match '[\w.+-]+@[\w-]+(\.[\w-]+)+') {
        $shown = $Matches[0]
        if ($shown -ine $FromAddress) { & $add 'High' "Display name shows the address $shown, but the message is from $FromAddress." }
    }
    if ($Authentication) {
        $label = @{ spf = 'SPF'; dkim = 'DKIM'; dmarc = 'DMARC'; compauth = 'Composite authentication' }
        foreach ($m in 'spf', 'dkim', 'dmarc', 'compauth') {
            $r = $Authentication.$m
            if (-not $r) { continue }
            switch ($r.result) {
                { $_ -in 'fail', 'permerror' } { & $add 'High' "$($label[$m]) $($r.result)." }
                { $_ -in 'softfail', 'temperror' } { & $add 'Medium' "$($label[$m]) $($r.result)." }
                'none' { if ($m -in 'spf', 'dmarc') { & $add 'Info' "No $($label[$m]) record for the sender domain." } }
                'bestguesspass' { & $add 'Info' 'The sender domain has no DMARC record (best guess pass).' }
            }
        }
        if (-not ($Authentication.spf -or $Authentication.dkim -or $Authentication.dmarc -or $Authentication.compauth)) {
            & $add 'Info' 'No Authentication-Results header. The message may be internal.'
        }
    }
    , $flags.ToArray()
}

function Get-MDOP2Sha256 {
    <#
    .SYNOPSIS
    Lower case hex SHA-256 of a byte array, computed in memory.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(Mandatory)][AllowEmptyCollection()][byte[]]$Bytes)
    [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($Bytes)).ToLowerInvariant()
}

function Get-MDOP2SafeFileName {
    <#
    .SYNOPSIS
    Builds a short, safe file name from a subject for the .eml download.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyString()][string]$Subject, [string]$Extension = '.eml', [int]$MaxLength = 80)
    $name = ([string]$Subject) -replace '[\x00-\x1F\x7F<>:"/\\|?*;]', ' ' -replace '[^\x20-\x7E]', '_' -replace '\s+', ' '
    $name = $name.Trim(' ', '.')
    if (-not $name) { $name = 'message' }
    if ($name.Length -gt $MaxLength) { $name = $name.Substring(0, $MaxLength).TrimEnd(' ', '.') }
    "$name$Extension"
}

#endregion

function ConvertTo-MDOP2UtcTimestamp {
    <#
    .SYNOPSIS
    Reads a timestamp as a UTC date, or returns $null when it isn't one. Text without a time zone is UTC, as every time
    from the server is.
    #>

    [CmdletBinding()]
    param([AllowNull()][object]$Value)
    if ($null -eq $Value -or '' -eq $Value) { return $null }
    # ConvertFrom-Json turns ISO strings into DateTime itself; older lines may still be strings. The EXO module hands
    # message trace times back with Kind Unspecified.
    if ($Value -is [datetime]) {
        if ($Value.Kind -eq [DateTimeKind]::Unspecified) { return [datetime]::SpecifyKind($Value, [DateTimeKind]::Utc) }
        return $Value.ToUniversalTime()
    }
    $d = [datetime]::MinValue
    if ([datetime]::TryParse([string]$Value, [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]'AdjustToUniversal, AssumeUniversal', [ref]$d)) { return $d }
    $null
}

function ConvertTo-MDOP2IsoDate {
    <#
    .SYNOPSIS
    Formats a timestamp as UTC ISO 8601 text (yyyy-MM-ddTHH:mm:ssZ, invariant culture), or returns $null when it isn't one.
    #>

    [CmdletBinding()]
    param([AllowNull()][object]$Value)
    $d = ConvertTo-MDOP2UtcTimestamp -Value $Value
    if ($null -eq $d) { return $null }
    $d.ToString('yyyy-MM-ddTHH:mm:ssZ', [Globalization.CultureInfo]::InvariantCulture)
}

function Get-MDOP2FreePort {
    <#
    .SYNOPSIS
    Returns a free TCP port on the loopback address: the OS picks one, which is then released for the caller to use.
    #>

    $l = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0)
    $l.Start()
    try { $l.LocalEndpoint.Port } finally { $l.Stop() }
}

function Select-MDOP2ActionHistory {
    <#
    .SYNOPSIS
    Turns Action events from the audit log into history rows, newest first, filtered by date, dry run and session.
    #>

    [CmdletBinding()]
    param(
        [AllowNull()][AllowEmptyCollection()][object[]]$Events,
        [Nullable[datetime]]$Since,
        [switch]$IncludeDryRun,
        [string]$SessionId,
        [int]$Max = 5000
    )
    $p = { param($o, $n) if ($o.PSObject.Properties[$n]) { $o.$n } else { $null } }
    $rows = foreach ($e in @($Events)) {
        if ($null -eq $e -or (& $p $e 'type') -ne 'Action') { continue }
        $when = ConvertTo-MDOP2UtcTimestamp (& $p $e 'timestamp')
        if (-not $when) { continue }
        if ($Since -and $when -lt $Since.ToUniversalTime()) { continue }
        $dry = (& $p $e 'dryRun') -eq $true
        if ($dry -and -not $IncludeDryRun) { continue }
        $sid = [string](& $p $e 'sessionId')
        if ($SessionId -and $sid -ne $SessionId) { continue }
        [pscustomobject]@{
            Timestamp         = $when
            SessionId         = $sid
            Admin             = [string](& $p $e 'adminUpn')
            InternetMessageId = [string](& $p $e 'internetMessageId')
            Mailbox           = [string](& $p $e 'mailbox')
            UserId            = [string](& $p $e 'userId')
            Action            = [string](& $p $e 'action')
            DryRun            = $dry
            FromFolder        = [string](& $p $e 'fromFolder')
            Result            = [string](& $p $e 'result')
            Hold              = [string](& $p $e 'hold')
            Error             = [string](& $p $e 'error')
        }
    }
    $sorted = @($rows | Sort-Object Timestamp -Descending)
    [pscustomobject]@{
        Rows      = @($sorted | Select-Object -First $Max)
        Total     = $sorted.Count
        Truncated = $sorted.Count -gt $Max
    }
}

function ConvertFrom-MDOP2SafeLink {
    <#
    .SYNOPSIS
    Returns the original URL behind a Defender Safe Links URL, or the input when it isn't one.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyString()][string]$Url)
    if (-not $Url) { return '' }
    # Only the real Safe Links hosts, so a crafted link such as https://evil.example?safelinks.protection.outlook.com/?url=...
    # can't hide its own host from a Link search
    if ($Url -match '^https?://([a-z0-9-]+\.)*safelinks\.protection\.outlook\.com/' -and $Url -match '[?&]url=([^&]+)') {
        return [uri]::UnescapeDataString($Matches[1])
    }
    $Url
}

function Get-MDOP2BodyLink {
    <#
    .SYNOPSIS
    Lists the links in a message body: href values in HTML plus bare http(s) URLs in the text. Safe Links are unwrapped.
    #>

    [CmdletBinding()]
    param([AllowNull()][AllowEmptyString()][string]$Body)
    if (-not $Body) { return , @() }
    $links = [Collections.Generic.List[string]]::new()
    foreach ($m in [regex]::Matches($Body, '(?i)\bhref\s*=\s*(?:"([^"]*)"|''([^'']*)''|([^\s>]+))')) {
        $v = if ($m.Groups[1].Success) { $m.Groups[1].Value } elseif ($m.Groups[2].Success) { $m.Groups[2].Value } else { $m.Groups[3].Value }
        $links.Add([Net.WebUtility]::HtmlDecode($v).Trim())
    }
    foreach ($m in [regex]::Matches([Net.WebUtility]::HtmlDecode($Body), '(?i)\bhttps?://[^\s"''<>]+')) { $links.Add($m.Value) }
    , @($links | ForEach-Object { ConvertFrom-MDOP2SafeLink $_ } | Where-Object { $_ } | Select-Object -Unique)
}

function ConvertTo-MDOP2PlainText {
    <#
    .SYNOPSIS
    Turns an HTML body into plain text for matching: drops style and script blocks and tags, decodes entities, collapses whitespace.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyString()][string]$Body)
    if (-not $Body) { return '' }
    $t = $Body -replace '(?is)<(style|script|head)\b.*?</\1\s*>', ' '
    $t = $t -replace '(?s)<!--.*?-->', ' '
    $t = $t -replace '<[^>]*>', ' '
    $t = [Net.WebUtility]::HtmlDecode($t)
    ($t -replace '\s+', ' ').Trim()
}

function Test-MDOP2BodyMatch {
    <#
    .SYNOPSIS
    Checks a message body for text (Mode Text) or for a link containing the text (Mode Link). Case-insensitive substring match.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [AllowNull()][AllowEmptyString()][string]$Body,
        [Parameter(Mandatory)][string]$Text,
        [ValidateSet('Text', 'Link')][string]$Mode = 'Text'
    )
    $needle = ($Text -replace '\s+', ' ').Trim()
    if (-not $needle) { return $true }
    if ($Mode -eq 'Link') {
        foreach ($l in (Get-MDOP2BodyLink -Body $Body)) {
            if ($l.IndexOf($needle, [StringComparison]::OrdinalIgnoreCase) -ge 0) { return $true }
        }
        return $false
    }
    (ConvertTo-MDOP2PlainText -Body $Body).IndexOf($needle, [StringComparison]::OrdinalIgnoreCase) -ge 0
}

function Get-MDOP2BodyShingle {
    <#
    .SYNOPSIS
    Returns the set of word sequences (shingles) in a message body, for comparing two bodies. Links, mail addresses and
    numbers become placeholders first, so a personal link, address or invoice number doesn't make two copies of the same
    mail look different. An empty set means the body has no text to compare.
    #>

    [CmdletBinding()]
    param(
        [AllowNull()][AllowEmptyString()][string]$Body,
        [ValidateRange(1, 10)][int]$Size = 3,
        [ValidateRange(1, 100000)][int]$MaxWords = 5000
    )
    $set = [Collections.Generic.HashSet[string]]::new()
    $text = (ConvertTo-MDOP2PlainText -Body $Body).ToLowerInvariant()
    $text = $text -replace '\b(?:https?://|www\.)\S+', ' url ' -replace '[^\s@<>()\[\]:;,]+@[^\s@<>()\[\]:;,]+', ' mail ' -replace '\d+', '0'
    $words = [regex]::Matches($text, '[\p{L}\p{N}]+')
    $n = [Math]::Min($words.Count, $MaxWords)
    if ($n -eq 0) { return , $set }
    $last = [Math]::Max(0, $n - $Size)
    for ($i = 0; $i -le $last; $i++) {
        $s = $words[$i].Value
        for ($j = 1; $j -lt $Size -and $i + $j -lt $n; $j++) { $s += ' ' + $words[$i + $j].Value }
        $null = $set.Add($s)
    }
    , $set
}

function Get-MDOP2ShingleSimilarity {
    <#
    .SYNOPSIS
    How alike two shingle sets from Get-MDOP2BodyShingle are, from 0 to 100 (shared sequences over all sequences). 100 only
    when both sets are equal. Returns nothing when either set is empty, so two mails without text never count as the same.
    #>

    [CmdletBinding()]
    param(
        [AllowNull()][Collections.Generic.HashSet[string]]$Reference,
        [AllowNull()][Collections.Generic.HashSet[string]]$Other
    )
    if ($null -eq $Reference -or $null -eq $Other -or $Reference.Count -eq 0 -or $Other.Count -eq 0) { return $null }
    $small, $large = if ($Reference.Count -le $Other.Count) { $Reference, $Other } else { $Other, $Reference }
    $shared = 0
    foreach ($s in $small) { if ($large.Contains($s)) { $shared++ } }
    [int][Math]::Floor(100.0 * $shared / ($small.Count + $large.Count - $shared))
}

function Test-MDOP2IPAddress {
    <#
    .SYNOPSIS
    True for one full IPv4 or IPv6 address. No range, no wildcard, no host name and no short forms such as 10.1.
    #>

    [CmdletBinding()]
    [OutputType([bool])]
    param([AllowNull()][AllowEmptyString()][string]$Value)
    $ip = $null
    [bool]($Value -match '^(\d{1,3}(\.\d{1,3}){3}|[0-9a-fA-F:.]*:[0-9a-fA-F:.]*)$' -and [ipaddress]::TryParse($Value, [ref]$ip))
}

function Get-MDOP2SimilarReason {
    <#
    .SYNOPSIS
    Says what a traced message has in common with the reference message: the sender address, the subject or the sending
    IP address. Empty values never match. Returns an array of 'sender', 'subject' and 'ip'.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)][object]$Reference, [Parameter(Mandatory)][object]$Candidate)
    $same = { param($a, $b) $x = ([string]$a).Trim(); [bool]($x -and $x -eq ([string]$b).Trim()) }
    , @(
        if (& $same $Reference.SenderAddress $Candidate.SenderAddress) { 'sender' }
        if (& $same $Reference.Subject $Candidate.Subject) { 'subject' }
        if (& $same $Reference.FromIP $Candidate.FromIP) { 'ip' }
    )
}

Export-ModuleMember -Function ConvertFrom-MDOP2SafeLink, Get-MDOP2BodyLink, ConvertTo-MDOP2PlainText, Test-MDOP2BodyMatch,
    Get-MDOP2BodyShingle, Get-MDOP2ShingleSimilarity, Get-MDOP2SimilarReason, Test-MDOP2IPAddress,
    ConvertTo-MDOP2ODataLiteral, ConvertTo-MDOP2UtcTimestamp, ConvertTo-MDOP2IsoDate, Get-MDOP2FreePort, Select-MDOP2ActionHistory, Get-MDOP2RetryDelay, Test-MDOP2RetryableStatus,
    Get-MDOP2DateRange, Split-MDOP2DateRange, ConvertTo-MDOP2AddressFilter, Test-MDOP2AddressMatch,
    Get-MDOP2ThrottleDelay, Group-MDOP2TraceResult, Get-MDOP2ResolveSkipReason, Get-MDOP2MailboxErrorState,
    Get-MDOP2ActionFolder, Get-MDOP2SameMailbox, New-MDOP2ActionPlan, Get-MDOP2MoveDecision, Get-MDOP2ActionSummary, ConvertTo-MDOP2AuditLine,
    Get-MDOP2AddressDomain, Test-MDOP2DomainAligned, Get-MDOP2HeaderValue, ConvertFrom-MDOP2AuthenticationResult,
    ConvertFrom-MDOP2AntispamReport, ConvertFrom-MDOP2MailDate, ConvertFrom-MDOP2ReceivedHeader, Get-MDOP2SuspicionFlag,
    Get-MDOP2Sha256, Get-MDOP2SafeFileName