@@ -36,6 +36,47 @@ function Send-CIPPScheduledTaskAlert {
3636 $Attachments
3737 )
3838
39+ function Format-AlertCellValue {
40+ <#
41+ ConvertTo-Html stringifies non-primitive property values with .ToString(),
42+ which renders nested collections/objects as .NET type names like
43+ 'System.Collections.Generic.List`1[System.Object]'. Flatten them to
44+ readable text instead. '[[BR]]' survives ConvertTo-Html's HTML encoding
45+ and is swapped for <br /> after the fragment is generated.
46+ #>
47+ param ($Value , [int ]$Depth = 0 )
48+ if ($null -eq $Value ) { return ' ' }
49+ if ($Value -is [string ]) { return $Value }
50+ if ($Value -is [datetime ]) { return $Value.ToString (' yyyy-MM-dd HH:mm:ss' ) }
51+ if ($Value -is [bool ] -or $Value.GetType ().IsPrimitive -or $Value -is [decimal ]) { return " $Value " }
52+ if ($Depth -ge 3 ) { return " $ ( try { $Value | ConvertTo-Json - Compress - Depth 5 } catch { $Value }) " }
53+ if ($Value -is [System.Collections.IDictionary ]) {
54+ return (@ ($Value.GetEnumerator () | ForEach-Object { " $ ( $_.Key ) : $ ( Format-AlertCellValue - Value $_.Value - Depth ($Depth + 1 )) " }) -join ' [[BR]]' )
55+ }
56+ if ($Value -is [System.Collections.IEnumerable ]) {
57+ return (@ ($Value | ForEach-Object { Format-AlertCellValue - Value $_ - Depth ($Depth + 1 ) }) -join ' [[BR]]' )
58+ }
59+ $Props = @ ($Value.PSObject.Properties )
60+ if ($Props.Count -gt 0 ) {
61+ return (@ ($Props | ForEach-Object { " $ ( $_.Name ) : $ ( Format-AlertCellValue - Value $_.Value - Depth ($Depth + 1 )) " }) -join ' [[BR]]' )
62+ }
63+ return " $Value "
64+ }
65+
66+ function ConvertTo-AlertDisplayRow {
67+ # Normalizes a result row for ConvertTo-Html: hashtables become objects (so they
68+ # render as columns instead of Keys/Values/Count) and every value is flattened.
69+ param ($Row )
70+ if ($null -eq $Row -or $Row -is [string ]) { return $Row }
71+ $Display = [ordered ]@ {}
72+ if ($Row -is [System.Collections.IDictionary ]) {
73+ foreach ($Key in $Row.Keys ) { $Display [[string ]$Key ] = Format-AlertCellValue - Value $Row [$Key ] - Depth 1 }
74+ } else {
75+ foreach ($Prop in $Row.PSObject.Properties ) { $Display [$Prop.Name ] = Format-AlertCellValue - Value $Prop.Value - Depth 1 }
76+ }
77+ [pscustomobject ]$Display
78+ }
79+
3980 try {
4081 Write-Information " Sending post-execution alerts for task $ ( $TaskInfo.Name ) "
4182
@@ -56,12 +97,15 @@ function Send-CIPPScheduledTaskAlert {
5697
5798 # Build HTML with adaptive table styling
5899 $TableDesign = '<style>table.adaptiveTable{border:1px solid currentColor;background-color:transparent;width:100%;text-align:left;border-collapse:collapse;opacity:0.9}table.adaptiveTable td,table.adaptiveTable th{border:1px solid currentColor;padding:8px 6px;opacity:0.8}table.adaptiveTable tbody td{font-size:13px}table.adaptiveTable tr:nth-child(even){background-color:rgba(128,128,128,0.1)}table.adaptiveTable thead{background-color:rgba(128,128,128,0.2);border-bottom:2px solid currentColor}table.adaptiveTable thead th{font-size:15px;font-weight:700;border-left:1px solid currentColor}table.adaptiveTable thead th:first-child{border-left:none}table.adaptiveTable tfoot{font-size:14px;font-weight:700;background-color:rgba(128,128,128,0.1);border-top:2px solid currentColor}table.adaptiveTable tfoot td{font-size:14px}@media (prefers-color-scheme: dark){table.adaptiveTable{opacity:0.95}table.adaptiveTable tr:nth-child(even){background-color:rgba(255,255,255,0.05)}table.adaptiveTable thead{background-color:rgba(255,255,255,0.1)}table.adaptiveTable tfoot{background-color:rgba(255,255,255,0.05)}}</style>'
100+ $EncodedTaskName = [System.Web.HttpUtility ]::HtmlEncode($TaskInfo.Name )
101+ $EncodedTenantName = [System.Web.HttpUtility ]::HtmlEncode($TenantFilter )
102+ $AlertHeader = " <div style=`" margin:0 0 14px;`" ><p style=`" margin:0 0 2px;font-size:15px;font-weight:600;`" >$EncodedTaskName </p><p style=`" margin:0;font-size:13px;opacity:0.75;`" >Tenant: <strong>$EncodedTenantName </strong></p></div>"
59103 $FinalResults = if ($Results -is [array ] -and $Results [0 ] -is [string ]) {
60104 $Results | ConvertTo-Html - Fragment - Property @ { l = ' Text' ; e = { $_ } }
61105 } else {
62- $Results | ConvertTo-Html - Fragment
106+ $Results | ForEach-Object { ConvertTo-AlertDisplayRow - Row $_ } | ConvertTo-Html - Fragment
63107 }
64- $HTML = $FinalResults -replace ' <table> ' , " This alert is for tenant $TenantFilter . <br /><br /> $TableDesign <table class=adaptiveTable>" | Out-String
108+ $HTML = $FinalResults -replace ' \[\[BR\]\] ' , ' <br />' -replace ' <table> ' , " $AlertHeader $TableDesign <table class=adaptiveTable>" | Out-String
65109
66110 # For alert tasks, add per-row snooze links to the email
67111 if ($TaskType -eq ' Alert' -and $Results -is [array ] -and $Results.Count -gt 0 -and $Results [0 ] -isnot [string ]) {
@@ -147,24 +191,70 @@ function Send-CIPPScheduledTaskAlert {
147191 switch - wildcard ($TaskInfo.PostExecution ) {
148192 ' *psa*' {
149193 $PsaSplitSent = $false
150- if ($TaskType -eq ' Alert' -and $Results -is [array ] -and $Results.Count -gt 0 -and $Results [0 ] -isnot [string ]) {
151- try {
152- $ExtConfigTable = Get-CIPPTable - TableName Extensionsconfig
153- $ExtConfig = (Get-CIPPAzDataTableEntity @ExtConfigTable ).config | ConvertFrom-Json - ErrorAction SilentlyContinue
154- $HaloConfig = $ExtConfig.HaloPSA
155-
156- # Per-task PsaTicketStrategy (configured on the alert) overrides the global
157- # HaloPSA.LinkTicketsToUsers toggle. Lets MSPs decide on a per-alert basis
158- # whether a wide alert (e.g. "users without MFA") should produce one ticket
159- # per user or one consolidated ticket per tenant.
194+ $TaskAffectedUser = $null
195+ try {
196+ $ExtConfigTable = Get-CIPPTable - TableName Extensionsconfig
197+ $ExtConfig = (Get-CIPPAzDataTableEntity @ExtConfigTable ).config | ConvertFrom-Json - ErrorAction SilentlyContinue
198+ $HaloConfig = $ExtConfig.HaloPSA
199+
200+ if ($HaloConfig -and $HaloConfig.Enabled ) {
201+ # Resolve an affected user from the scheduled task's own parameters first.
202+ # User-targeted tasks (Edit user, license changes, sign-in state) carry the
203+ # user in Parameters while their results often have no UPN column. A UPN-like
204+ # value maps to UPN, a GUID to AzureOID; New-CippExtAlert resolves the rest
205+ # via Graph. Placeholder values (%userid%) from trigger tasks are skipped.
206+ $UserUpn = $null
207+ $UserOid = $null
208+ $UserDisplay = $null
209+ $GuidPattern = ' ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
210+
211+ # Add/Edit user tasks nest the full target user in a UserObj parameter.
212+ # The UPN is either stored directly or composed from username + domain
213+ # (matching how New-CIPPUser/Set-CIPPUser build it).
214+ $UserObj = $TaskParameters.UserObj
215+ if ($UserObj ) {
216+ if ($UserObj.userPrincipalName -is [string ] -and $UserObj.userPrincipalName -match ' @' ) {
217+ $UserUpn = $UserObj.userPrincipalName
218+ } elseif ($UserObj.username -is [string ] -and -not [string ]::IsNullOrWhiteSpace($UserObj.username )) {
219+ $UserDomain = if ($UserObj.Domain -is [string ] -and $UserObj.Domain ) { $UserObj.Domain } else { $UserObj.primDomain.value }
220+ if ($UserDomain -is [string ] -and -not [string ]::IsNullOrWhiteSpace($UserDomain )) { $UserUpn = " $ ( $UserObj.username ) @$UserDomain " }
221+ }
222+ if ($UserObj.id -is [string ] -and $UserObj.id -match $GuidPattern ) { $UserOid = $UserObj.id }
223+ if ($UserObj.displayName -is [string ] -and -not [string ]::IsNullOrWhiteSpace($UserObj.displayName )) { $UserDisplay = $UserObj.displayName }
224+ }
225+
226+ if (-not $UserUpn -and -not $UserOid ) {
227+ $ParamKeys = @ (' UserPrincipalName' , ' UPN' , ' username' , ' user' , ' userid' , ' id' )
228+ foreach ($ParamKey in $ParamKeys ) {
229+ $ParamValue = $TaskParameters .$ParamKey
230+ # Form fields store autocomplete selections as {label, value} objects.
231+ if ($ParamValue -isnot [string ] -and $null -ne $ParamValue.value -and $ParamValue.value -is [string ]) { $ParamValue = $ParamValue.value }
232+ if ($ParamValue -isnot [string ] -or [string ]::IsNullOrWhiteSpace($ParamValue ) -or $ParamValue -match ' %' ) { continue }
233+ if (-not $UserUpn -and $ParamValue -match ' @' ) { $UserUpn = $ParamValue }
234+ elseif (-not $UserOid -and $ParamValue -match $GuidPattern ) { $UserOid = $ParamValue }
235+ }
236+ }
237+
238+ if ($UserUpn -or $UserOid ) {
239+ $TaskAffectedUser = [pscustomobject ]@ {
240+ UPN = $UserUpn
241+ AzureOID = $UserOid
242+ DisplayName = $UserDisplay
243+ }
244+ }
245+
246+ # Per-task PsaTicketStrategy (configured on the task) overrides the global
247+ # HaloPSA.LinkTicketsToUsers toggle. Lets MSPs decide on a per-task basis
248+ # whether a wide result set (e.g. "users without MFA") should produce one
249+ # ticket per user or one consolidated ticket per tenant.
160250 $TaskStrategy = $TaskInfo.PsaTicketStrategy
161251 $ShouldSplit = switch ($TaskStrategy ) {
162252 ' split' { $true }
163253 ' consolidated' { $false }
164254 default { [bool ]$HaloConfig.LinkTicketsToUsers }
165255 }
166256
167- if ($HaloConfig -and $HaloConfig .Enabled - and $ShouldSplit ) {
257+ if ($ShouldSplit -and $Results -is [ array ] -and $Results .Count -gt 0 - and $Results [ 0 ] -isnot [ string ] ) {
168258 $UpnFieldCandidates = @ (' UserPrincipalName' , ' userPrincipalName' , ' UPN' , ' userId' , ' Userkey' )
169259 $RowProperties = $Results [0 ].PSObject.Properties.Name
170260 $UpnField = $UpnFieldCandidates | Where-Object { $_ -in $RowProperties } | Select-Object - First 1
@@ -177,12 +267,15 @@ function Send-CIPPScheduledTaskAlert {
177267
178268 foreach ($Group in $Groups ) {
179269 $GroupKey = $Group.Name
180- $GroupHTMLFragment = $Group.Group | ConvertTo-Html - Fragment
181- $GroupHTML = $GroupHTMLFragment -replace ' <table> ' , " This alert is for tenant $TenantFilter . <br /><br /> $TableDesign <table class=adaptiveTable>" | Out-String
270+ $GroupHTMLFragment = $Group.Group | ForEach-Object { ConvertTo-AlertDisplayRow - Row $_ } | ConvertTo-Html - Fragment
271+ $GroupHTML = $GroupHTMLFragment -replace ' \[\[BR\]\] ' , ' <br />' -replace ' <table> ' , " $AlertHeader $TableDesign <table class=adaptiveTable>" | Out-String
182272
183273 if ([string ]::IsNullOrWhiteSpace($GroupKey )) {
184- # Rows without a usable user identifier - send as a single tenant-scoped ticket.
185- Send-CIPPAlert - Type ' psa' - Title $title - HTMLContent $GroupHTML - TenantFilter $TenantFilter
274+ # Rows without a usable user identifier - fall back to the
275+ # task-level affected user if one was resolved.
276+ $GroupParams = @ { Type = ' psa' ; Title = $title ; HTMLContent = $GroupHTML ; TenantFilter = $TenantFilter }
277+ if ($TaskAffectedUser ) { $GroupParams.AffectedUser = $TaskAffectedUser }
278+ Send-CIPPAlert @GroupParams
186279 } else {
187280 $GroupDisplayName = if ($DisplayField ) { $Group.Group [0 ].$DisplayField } else { $null }
188281 $UserLabel = if ($GroupDisplayName ) { " $GroupDisplayName ($GroupKey )" } else { $GroupKey }
@@ -197,13 +290,15 @@ function Send-CIPPScheduledTaskAlert {
197290 $PsaSplitSent = $true
198291 }
199292 }
200- } catch {
201- Write-Information " Failed to split PSA alert by user, falling back to consolidated ticket: $ ( $_.Exception.Message ) "
202293 }
294+ } catch {
295+ Write-Information " Failed to resolve PSA affected user or split by user, falling back to consolidated ticket: $ ( $_.Exception.Message ) "
203296 }
204297
205298 if (-not $PsaSplitSent ) {
206- Send-CIPPAlert - Type ' psa' - Title $title - HTMLContent $HTML - TenantFilter $TenantFilter
299+ $PsaParams = @ { Type = ' psa' ; Title = $title ; HTMLContent = $HTML ; TenantFilter = $TenantFilter }
300+ if ($TaskAffectedUser ) { $PsaParams.AffectedUser = $TaskAffectedUser }
301+ Send-CIPPAlert @PsaParams
207302 }
208303 }
209304 ' *email*' {
0 commit comments