Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions backend/Config/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -67057,8 +67057,25 @@
"tags": [
"Email-Exchange > Spamfilter"
],
"description": "Lists user reported email threat submissions (Defender Submissions with source 'user') for a tenant.",
"description": "Lists user reported messages (Outlook Report button) for a tenant. Source=AuditLog (default) reads the unified\naudit log two weeks per page, Source=Submissions reads the Defender Submissions list from Graph.",
"parameters": [
{
"name": "nextLink",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "Source",
"in": "query",
"description": "AuditLog (default) or Submissions",
"required": false,
"schema": {
"type": "string"
}
},
{
"$ref": "#/components/parameters/tenantFilter"
}
Expand Down Expand Up @@ -67152,9 +67169,9 @@
"type": "string",
"x-cipp-field-source": "graph-entity"
},
"source": {
"Source": {
"type": "object",
"x-cipp-field-source": "graph-entity"
"x-cipp-field-source": "graph-entity,frontend"
},
"Status": {
"type": "object",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,34 +5,39 @@ function Invoke-ListUserReportedMessages {
.ROLE
Exchange.SpamFilter.Read
.DESCRIPTION
Lists user reported email threat submissions (Defender Submissions with source 'user') for a tenant.
Lists user reported messages (Outlook Report button) for a tenant. Source=AuditLog (default) reads the unified
audit log two weeks per page, Source=Submissions reads the Defender Submissions list from Graph.
#>
[CmdletBinding()]
param($Request, $TriggerMetadata)

$TenantFilter = $Request.Query.tenantFilter
# AuditLog (default) or Submissions
$Source = $Request.Query.Source

try {
if ($TenantFilter -eq 'AllTenants') {
$GraphRequest = @()
$Metadata = [PSCustomObject]@{
QueueMessage = 'User reported messages are loaded per tenant. Select a tenant to view its reported messages.'
}
} else {
} elseif ($Source -eq 'Submissions') {
# Defender Submissions via Graph: only what reached Microsoft analysis, Outlook reports mostly show up once an
# admin submits them (source 'administrator')
$Submissions = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/security/threatSubmission/emailThreats' -tenantid $TenantFilter -AsApp $true
$Results = [System.Collections.Generic.List[object]]::new()
foreach ($Submission in $Submissions) {
# Admin submissions share the same Graph collection; this page only covers user reports
if ($Submission.source -ne 'user') { continue }
$Results.Add([PSCustomObject]@{
ReportedDateTime = $Submission.createdDateTime
ReceivedDateTime = $Submission.receivedDateTime
Subject = $Submission.subject ?? $Submission.emailSubject
Sender = $Submission.sender
SenderIP = $Submission.senderIP
RecipientEmail = $Submission.recipientEmailAddress
ReportedBy = $Submission.createdBy.user.displayName
ReporterEmail = $Submission.createdBy.user.email
# Graph returns createdBy flat ({ id, displayName, email }), the docs sample nests it under 'user'
ReportedBy = $Submission.createdBy.displayName ?? $Submission.createdBy.user.displayName ?? $Submission.createdBy.email ?? $Submission.createdBy.user.email
ReporterEmail = $Submission.createdBy.email ?? $Submission.createdBy.user.email
Source = $Submission.source
Category = $Submission.category
OriginalCategory = $Submission.originalCategory
Status = $Submission.status
Expand All @@ -45,6 +50,78 @@ function Invoke-ListUserReportedMessages {
})
}
$GraphRequest = $Results | Sort-Object -Property ReportedDateTime -Descending
} else {
# Graph's threatSubmission list skips Outlook Report button submissions, the unified audit log has them all.
# One two-week window per call, Metadata.nextLink points at the previous window so the table pages back
# until the audit log's 180-day standard retention.
# ponytail: plain searches capped at 5000 rows (no ReturnLargeSet session, that timed out at ~100s on a real
# tenant). A capped window is halved until it fits. Move to a cached DB job if this becomes more than a demo
$WindowDays = 14
$Now = (Get-Date).ToUniversalTime()
$Oldest = $Now.AddDays(-180)
$EndDate = if ($Request.Query.nextLink) { ([datetime]::Parse($Request.Query.nextLink, [cultureinfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AdjustToUniversal -bor [System.Globalization.DateTimeStyles]::AssumeUniversal)) } else { $Now }
$StartDate = $EndDate.AddDays(-$WindowDays)
if ($StartDate -lt $Oldest) { $StartDate = $Oldest }

$ResultSize = 5000
# A capped search drops rows, so halve the window toward EndDate until it fits. The next page then starts at the
# narrowed StartDate, nothing between the windows is skipped. Below an hour it stays capped and says so.
do {
$SearchParams = @{
StartDate = $StartDate
EndDate = $EndDate
RecordType = 'MailSubmission'
Operations = @('UserSubmission', 'CustomSubmission', 'AttackSimUserSubmission')
ResultSize = $ResultSize
}
# A search with no hits returns nothing, not an empty array
$Records = @(New-ExoRequest -tenantid $TenantFilter -cmdlet 'Search-UnifiedAuditLog' -cmdParams $SearchParams | Where-Object { $_.AuditData })
$Capped = $Records.Count -ge $ResultSize
$CanSplit = ($EndDate - $StartDate).TotalHours -gt 1
if ($Capped -and $CanSplit) { $StartDate = $StartDate.AddTicks([long](($EndDate - $StartDate).Ticks / 2)) }
} while ($Capped -and $CanSplit)
# Audit timestamps are UTC without an offset (ConvertFrom-Json makes them Unspecified datetimes), stamp them as UTC
$AsUtc = { param($Value) if ($Value -is [datetime]) { [datetime]::SpecifyKind($Value, 'Utc').ToString('o') } elseif ($Value) { "$($Value)Z" } }
$Results = [System.Collections.Generic.List[object]]::new()
# A reported phish simulation logs both UserSubmission and AttackSimUserSubmission for the same submission,
# keep one row per submission and prefer the simulation record
$Seen = [System.Collections.Generic.HashSet[string]]::new()
foreach ($Record in ($Records | Sort-Object { $_.Operations -ne 'AttackSimUserSubmission' })) {
$Data = try { $Record.AuditData | ConvertFrom-Json -ErrorAction Stop } catch { $null }
if (-not $Data) { continue }
if (-not $Seen.Add([string]($Data.SubmissionId ?? $Data.Id))) { continue }
$Results.Add([PSCustomObject]@{
ReportedDateTime = & $AsUtc ($Data.CreationTime ?? $Record.CreationDate)
ReceivedDateTime = & $AsUtc $Data.MessageDate
Subject = $Data.Subject
Sender = $Data.P2Sender ?? $Data.P1Sender
SenderIP = $Data.SenderIP
RecipientEmail = @($Data.Recipients) -join ', '
ReportedBy = $Data.UserId
ReporterEmail = $Data.UserId
Source = $Data.Operation
# SubmissionType is an undocumented number (0/1 seen), shown raw for the demo
Category = "$($Data.SubmissionType)"
OriginalCategory = ($Data.ExtendedProperties | Where-Object Name -EQ 'OriginalVerdict').Value
Status = $Data.SubmissionState
ResultCategory = $Data.RescanResult.RescanVerdict ?? ($Data.ExtendedProperties | Where-Object Name -EQ 'RescanVerdict').Value
ResultDetail = $Data.DeliveryMessageInfo.FinalFilterVerdict
AdminReviewResult = $null
InternetMessageId = $Data.InternetMessageId
Id = $Data.SubmissionId ?? $Data.Id
Tenant = $TenantFilter
})
}
$GraphRequest = $Results | Sort-Object -Property ReportedDateTime -Descending
$Metadata = [PSCustomObject]@{
WindowStart = $StartDate.ToString('o')
WindowEnd = $EndDate.ToString('o')
Complete = -not $Capped
}
if ($Capped) {
$Metadata | Add-Member -NotePropertyName Warning -NotePropertyValue "More than $ResultSize reports between $($StartDate.ToString('u')) and $($EndDate.ToString('u')), only the first $ResultSize are listed."
}
if ($StartDate -gt $Oldest) { $Metadata | Add-Member -NotePropertyName nextLink -NotePropertyValue $StartDate.ToString('o') }
}
$Body = [PSCustomObject]@{
Results = @($GraphRequest)
Expand Down
94 changes: 94 additions & 0 deletions backend/Tests/Endpoint/Invoke-ListUserReportedMessages.Tests.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Pester tests for Invoke-ListUserReportedMessages.
#
# Pins the audit log paging contract: a capped two-week window is halved until it fits and the next
# page continues from the narrowed start (no reports skipped), paging stops at the 180-day retention,
# and a reported phish simulation logged twice collapses to one row.

BeforeAll {
$RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath))
$FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-ListUserReportedMessages.ps1' -File -ErrorAction SilentlyContinue |
Select-Object -First 1 -ExpandProperty FullName
if (-not $FunctionPath) { throw 'Could not locate Invoke-ListUserReportedMessages.ps1 under Modules/' }

class HttpResponseContext {
[int]$StatusCode
[object]$Body
}
$Accelerators = [PSObject].Assembly.GetType('System.Management.Automation.TypeAccelerators')
if (-not ('HttpStatusCode' -as [type])) {
$Accelerators::Add('HttpStatusCode', [System.Net.HttpStatusCode])
}

function New-ExoRequest { param($tenantid, $cmdlet, $cmdParams) }
function New-GraphGetRequest { param($uri, $tenantid, $AsApp) }
function Get-NormalizedError { param($Message) $Message }

. $FunctionPath

function New-AuditRecord {
param([string]$Operation = 'UserSubmission', [string]$SubmissionId = [guid]::NewGuid().ToString())
[pscustomobject]@{
Operations = $Operation
AuditData = (@{
CreationTime = '2026-10-09T07:34:03'
Operation = $Operation
UserId = 'user@contoso.com'
Subject = 'Reported'
SubmissionId = $SubmissionId
RescanResult = @{ RescanVerdict = 'Phish' }
} | ConvertTo-Json -Compress)
}
}

function Invoke-List {
param([hashtable]$Query)
$Request = [pscustomobject]@{ Query = [pscustomobject]$Query }
Invoke-ListUserReportedMessages -Request $Request -TriggerMetadata $null
}
}

Describe 'Invoke-ListUserReportedMessages (audit log source)' {
It 'halves a capped window and continues paging from the narrowed start' {
# More than 5000 records in any window over 3.5 days, a handful below that
Mock New-ExoRequest {
$Span = ($cmdParams.EndDate - $cmdParams.StartDate).TotalDays
$Count = if ($Span -gt 3.5) { 5000 } else { 3 }
1..$Count | ForEach-Object { New-AuditRecord }
}

$Response = Invoke-List @{ tenantFilter = 'contoso.onmicrosoft.com' }

# 14 -> 7 -> 3.5 days: three searches, the last one fits
Should -Invoke New-ExoRequest -Times 3 -Exactly
$Response.Body.Results.Count | Should -Be 3
$Response.Body.Metadata.Complete | Should -BeTrue
$Start = [datetime]::Parse($Response.Body.Metadata.WindowStart).ToUniversalTime()
$End = [datetime]::Parse($Response.Body.Metadata.WindowEnd).ToUniversalTime()
($End - $Start).TotalDays | Should -Be 3.5
$Response.Body.Metadata.nextLink | Should -Be $Response.Body.Metadata.WindowStart
}

It 'stops linking further pages at the 180-day retention' {
Mock New-ExoRequest { New-AuditRecord }

$Response = Invoke-List @{ tenantFilter = 'contoso.onmicrosoft.com'; nextLink = (Get-Date).ToUniversalTime().AddDays(-175).ToString('o') }

$Response.Body.Metadata.PSObject.Properties.Name | Should -Not -Contain 'nextLink'
$Start = [datetime]::Parse($Response.Body.Metadata.WindowStart).ToUniversalTime()
((Get-Date).ToUniversalTime() - $Start).TotalDays | Should -BeLessOrEqual 180.01
}

It 'collapses a reported phish simulation logged under both operations into one row' {
Mock New-ExoRequest {
New-AuditRecord -Operation 'UserSubmission' -SubmissionId 'sim-1'
New-AuditRecord -Operation 'AttackSimUserSubmission' -SubmissionId 'sim-1'
New-AuditRecord -Operation 'UserSubmission' -SubmissionId 'real-1'
}

$Response = Invoke-List @{ tenantFilter = 'contoso.onmicrosoft.com' }

$Response.Body.Results.Count | Should -Be 2
($Response.Body.Results | Where-Object Id -EQ 'sim-1').Source | Should -Be 'AttackSimUserSubmission'
$Response.Body.Results[0].ReportedDateTime | Should -Match 'Z$'
}
}
Loading
Loading