public/Get-MsecDefenderTeamsMessage.ps1

function Get-MsecDefenderTeamsMessage {
    <#
    .SYNOPSIS
        Teams messages from the Defender advanced-hunting MessageEvents table, one row each,
        with recipients and URL domains resolved - narrowed server-side, then filtered in
        PowerShell.

    .DESCRIPTION
        The Teams counterpart to Get-MsecDefenderEmail, with three differences that are not
        cosmetic and will change how you query it.

        THERE IS NO SENDER IP, SO THERE IS NO COUNTRY. Teams is not SMTP: a message arrives
        through Microsoft's service from an authenticated identity, and MessageEvents has no
        address column of any kind. The geography question that SenderCountry answers for mail
        cannot be asked here at all. What replaces it is identity and trust boundary -
        SenderType (User, Anonymous, Applications), IsExternalThread, and whether the thread is
        owned by this tenant.

        ONE ROW PER MESSAGE, NOT PER RECIPIENT - the opposite of EmailEvents. Recipients arrive
        as the RecipientDetails JSON array and are flattened into RecipientAddress, a string[],
        so a message to nine people is one row with nine addresses. Test it with -contains, not
        -eq. A sum over rows is a count of messages; a count of people needs the array.

        SUBJECT IS EMPTY FOR CHAT AND MEETING MESSAGES. It is populated for channel posts
        (ThreadType 'space' and 'topic') and essentially never for one-to-one or group chat,
        which is most of the traffic. -Subject will therefore silently match nothing across the
        bulk of the table; -ThreadName is the usable handle for chat, because that carries the
        conversation name. Both are offered rather than one, because channel posts really do
        have subjects.

        THE MESSAGE BODY IS NOT IN THIS TABLE AND NEITHER IS ANY ATTACHMENT CONTENT. What is
        here is metadata plus Defender's verdict. For the links, UrlCount and UrlDomains are
        joined from MessageUrlInfo on TeamsMessageId - both tables cover the same window, so a
        message with no row there genuinely has no URLs and counts as zero rather than unknown.

        VERDICT COLUMNS CAN BE EMPTY ACROSS THE WHOLE TABLE AND THAT IS NOT A BUG. ThreatTypes,
        DetectionMethods, ConfidenceLevel and SafetyTip are populated only where Defender for
        Office 365 acted on a Teams message. A tenant that has had none will see them blank
        everywhere; they are returned regardless, because their absence is the finding when you
        expected otherwise.

        POST-DELIVERY ACTIONS ARE NOT JOINED. MessagePostDeliveryEvents is a separate table
        holding what happened to a message after it landed, so there is no equivalent of mail's
        LatestDeliveryLocation here: DeliveryLocation says where it was delivered, not whether
        it was removed afterwards.

        Runs as the msec app and needs 'ThreatHunting.Read.All'. Advanced hunting retains 30
        days, which is what -Days is capped at.

    .PARAMETER Days
        How far back to look. Default 7, maximum 30 - the advanced-hunting retention ceiling.

    .PARAMETER SenderAddress
        Sender's SMTP address. Exact, case-insensitive.

    .PARAMETER RecipientAddress
        Substring match against the raw RecipientDetails JSON, because recipients are an array
        rather than a column. An address matches wherever it appears in that array.

    .PARAMETER Subject
        Substring match, case-insensitive. Only meaningful for channel posts - see the
        description. Use -ThreadName for chat.

    .PARAMETER ThreadName
        Substring match on the conversation name. The usable handle for chat, where Subject is
        empty.

    .PARAMETER ThreadType
        Observed values are 'chat', 'space' (channel), 'meeting' and 'topic'. Not a ValidateSet
        on purpose - Microsoft adds thread types, and rejecting an unknown one here would hide
        traffic rather than reveal it.

    .PARAMETER SenderType
        Observed values are 'User', 'Anonymous' and 'Applications'. Anonymous and Applications
        are worth separating out: one is an unauthenticated participant, the other a bot or
        connector posting on its own.

    .PARAMETER ExternalOnly
        Only threads that cross the tenant boundary (IsExternalThread). The closest thing Teams
        has to mail's inbound direction.

    .PARAMETER ThreatType
        Phish, Spam or Malware. ThreatTypes is multi-valued, so asking for one does not exclude
        the other.

    .PARAMETER ThreatsOnly
        Any verdict at all - ThreatTypes non-empty.

    .PARAMETER MaxMessages
        Row ceiling, newest first. Defaults to the /security/runHuntingQuery ceiling, so it only
        binds where the service would have.

    .EXAMPLE
        Get-MsecDefenderTeamsMessage -Days 30 -ExternalOnly |
            Group-Object SenderEmailAddress | Sort-Object Count -Descending

        Who is talking to this tenant from outside it, and how much.

    .EXAMPLE
        Get-MsecDefenderTeamsMessage -Days 30 -SenderType Anonymous, Applications

        Messages that did not come from a signed-in person in this tenant.

    .EXAMPLE
        Get-MsecDefenderTeamsMessage -Days 30 -ExternalOnly |
            Where-Object UrlCount -gt 0 |
            Select-Object Timestamp, SenderEmailAddress, ThreadName, UrlDomains

        External messages carrying links - the Teams phishing shape.

    .EXAMPLE
        Get-MsecDefenderTeamsMessage -Days 30 |
            Where-Object RecipientAddress -contains 'someone@contoso.com'

        Everything that reached one person. -contains, not -eq: recipients are an array.

    .EXAMPLE
        Get-MsecDefenderTeamsMessage -Days 30 -ThreatsOnly

        Every Teams message Defender gave a verdict to. An empty result is a real answer.

    .OUTPUTS
        PSCustomObject per message, PSTypeName 'MsecDefenderTeamsMessage'.

    .NOTES
        TeamsMessageId is the join key to MessageUrlInfo and MessagePostDeliveryEvents.
        ThreadId identifies the conversation across messages.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter()]
        [ValidateRange(1, 30)]
        [int] $Days = 7,

        [Parameter()]
        [string[]] $SenderAddress,

        [Parameter()]
        [string[]] $RecipientAddress,

        [Parameter()]
        [string[]] $Subject,

        [Parameter()]
        [string[]] $ThreadName,

        [Parameter()]
        [string[]] $ThreadType,

        [Parameter()]
        [string[]] $SenderType,

        [Parameter()]
        [switch] $ExternalOnly,

        [Parameter()]
        [ValidateSet('Phish', 'Spam', 'Malware')]
        [string[]] $ThreatType,

        [Parameter()]
        [switch] $ThreatsOnly,

        [Parameter()]
        [ValidateRange(1, 100000)]
        [int] $MaxMessages = 100000
    )

    Assert-MsecSession

    $invoke = {
        param([string] $Kql)
        try {
            Invoke-MsecGraphRequest -Path '/v1.0/security/runHuntingQuery' -Method POST -Body @{ Query = $Kql }
        }
        catch {
            if ($_.Exception.Message -match '403|Forbidden') {
                throw "Forbidden when calling /security/runHuntingQuery. The msec app needs the 'ThreatHunting.Read.All' application permission (admin consent required). Original error: $($_.Exception.Message)"
            }
            throw
        }
    }

    # .Replace, not -replace: a regex replacement string eats $ and ` sequences, and these are
    # caller-supplied subjects and addresses.
    $lit  = { param([string] $s) '"' + $s.Replace('\', '\\').Replace('"', '\"') + '"' }
    $list = { param([string[]] $v) ($v | ForEach-Object { & $lit $_ }) -join ', ' }
    $any  = {
        param([string] $Expr, [string[]] $Values)
        '(' + (($Values | ForEach-Object { "$Expr contains $(& $lit $_)" }) -join ' or ') + ')'
    }

    # Every filter is on a raw MessageEvents column, so the count query needs neither the
    # mv-apply nor the join - and still counts exactly the population the rows come from.
    $filters = @("Timestamp >= ago(${Days}d)")
    if ($SenderAddress) { $filters += "SenderEmailAddress in~ ($(& $list $SenderAddress))" }
    if ($ThreadType)    { $filters += "ThreadType in~ ($(& $list $ThreadType))" }
    if ($SenderType)    { $filters += "SenderType in~ ($(& $list $SenderType))" }
    if ($ExternalOnly)  { $filters += 'IsExternalThread' }
    if ($ThreatsOnly)   { $filters += 'isnotempty(ThreatTypes)' }
    if ($ThreatType)    { $filters += "ThreatTypes has_any ($(& $list $ThreatType))" }
    # contains, not has: `has` is token-based, and an address or a hyphenated thread name is not
    # one token. Recipients are matched against the raw JSON because they are an array.
    if ($RecipientAddress) { $filters += & $any 'tostring(RecipientDetails)' $RecipientAddress }
    if ($Subject)          { $filters += & $any 'Subject'    $Subject }
    if ($ThreadName)       { $filters += & $any 'ThreadName' $ThreadName }

    $where = '| where ' + ($filters -join "`n and ")
    $base  = "MessageEvents`n$where"

    $total = [int](((& $invoke "$base`n| count").Results | Select-Object -First 1).Count ?? 0)

    if ($total -gt $MaxMessages) {
        Write-Warning ("{0} message(s) match; returning the {1} newest. Any filtering you do downstream is over that subset only - narrow with the filter parameters, which apply BEFORE the ceiling." -f $total, $MaxMessages)
    }

    # mv-apply flattens the recipient array into a string[] so callers can use -contains exactly.
    # Joining the addresses into one string here would force -like '*someone@x*' on every such
    # query, and a substring match reports 'anna@x' as a hit for 'joanna@x'.
    $kql = @"
$base
| mv-apply r = todynamic(RecipientDetails) on (
    summarize RecipientAddress = make_list(tostring(r.RecipientSmtpAddress)))
| join kind=leftouter (
    MessageUrlInfo
    | summarize UrlCount = count(), UrlDomains = make_set(UrlDomain, 10) by TeamsMessageId
  ) on TeamsMessageId
| project Timestamp, ThreadType, ThreadSubType, ThreadName, IsExternalThread, IsOwnedThread,
          SenderEmailAddress, SenderDisplayName, SenderType, SenderObjectId,
          RecipientAddress, Subject, MessageType, MessageSubtype,
          ThreatTypes, DetectionMethods, ConfidenceLevel, SafetyTip,
          DeliveryAction, DeliveryLocation, UrlCount, UrlDomains,
          TeamsMessageId, ThreadId, MessageId, GroupName, LastEditedTime
| sort by Timestamp desc
| take $MaxMessages
"@


    # A leftouter join that misses returns an EMPTY PSCustomObject, not $null - so ?? never
    # fires and a cast is what breaks. [int] on it throws; [bool] on it would be worse, because
    # a non-null object is $true and a missing IsExternalThread would silently read as external.
    # Everything coming back from the API therefore goes through these.
    $text = { param($v) if ($null -eq $v) { '' } else { [string]$v } }
    $num  = { param($v) $n = 0; if ([int]::TryParse((& $text $v), [ref] $n)) { $n } else { 0 } }
    $flag = { param($v) (& $text $v) -in @('1', 'true', 'True') }
    $when = { param($v) $d = [datetime]::MinValue
              if ([datetime]::TryParse((& $text $v), [ref] $d)) { $d } else { $null } }
    $str  = { param($v) $s = & $text $v; if ($s) { $s } else { $null } }
    $arr  = { param($v) @($v) | Where-Object { (& $text $_) } }
    foreach ($r in @((& $invoke $kql).Results)) {
        [PSCustomObject]@{
            PSTypeName         = 'MsecDefenderTeamsMessage'
            Timestamp          = & $when $r.Timestamp
            ThreadType         = & $str $r.ThreadType
            ThreadSubType      = & $str $r.ThreadSubType
            ThreadName         = & $str $r.ThreadName
            # The trust boundary. Teams has no sender IP, so this and SenderType are what stand
            # in for mail's direction and geography.
            IsExternalThread   = & $flag $r.IsExternalThread
            IsOwnedThread      = & $flag $r.IsOwnedThread
            SenderEmailAddress = & $str $r.SenderEmailAddress
            SenderDisplayName  = & $str $r.SenderDisplayName
            SenderType         = & $str $r.SenderType
            SenderObjectId     = & $str $r.SenderObjectId
            # string[], so -contains tests it exactly. One row is one MESSAGE, not one recipient.
            RecipientAddress   = @(& $arr $r.RecipientAddress)
            # Empty on chat and meeting messages - see the help. Not a gap in the data.
            Subject            = & $str $r.Subject
            MessageType        = & $str $r.MessageType
            MessageSubtype     = & $str $r.MessageSubtype
            # Blank across the whole table in a tenant Defender has never acted on in Teams.
            ThreatTypes        = & $str $r.ThreatTypes
            DetectionMethods   = & $str $r.DetectionMethods
            ConfidenceLevel    = & $str $r.ConfidenceLevel
            SafetyTip          = & $str $r.SafetyTip
            DeliveryAction     = & $str $r.DeliveryAction
            # Where it was delivered. NOT whether it was removed afterwards - that lives in
            # MessagePostDeliveryEvents, which this command does not join.
            DeliveryLocation   = & $str $r.DeliveryLocation
            # Joined from MessageUrlInfo over the same window, so absence is a measured zero.
            UrlCount           = & $num $r.UrlCount
            UrlDomains         = @(& $arr $r.UrlDomains)
            TeamsMessageId     = & $str $r.TeamsMessageId
            ThreadId           = & $str $r.ThreadId
            MessageId          = & $str $r.MessageId
            GroupName          = & $str $r.GroupName
            LastEditedTime     = & $when $r.LastEditedTime
        }
    }
}