44<#
55. SYNOPSIS
66Checks if a set of Calendar Logs is from the Organizer.
7+
8+ Modern Sharing can replicate the Organizer's items into a Delegate's mailbox, so a
9+ positive ResponseType/ExternalSharingMasterId match is not sufficient on its own. When
10+ an Identity is supplied this function additionally validates that the From / Organizer
11+ on the qualifying row resolves to the user being analyzed.
712#>
813function SetIsOrganizer {
914 param (
10- $CalLogs
15+ $CalLogs ,
16+ [string ] $Identity
1117 )
1218 [bool ] $IsOrganizer = $false
1319
1420 foreach ($CalLog in $CalLogs ) {
1521 if ($CalLog.ItemClass -eq " Ipm.Appointment" -and
1622 $CalLog.ExternalSharingMasterId -eq " NotFound" -and
1723 ($CalLog.ResponseType -eq " 1" -or $CalLog.ResponseType -eq " Organizer" )) {
24+
25+ if (-not [string ]::IsNullOrEmpty($Identity ) -and -not (Test-CalLogFromMatchesIdentity - CalLog $CalLog - Identity $Identity )) {
26+ Write-Verbose " SetIsOrganizer: ResponseType is Organizer on this row but From does not resolve to [$Identity ]. Likely a Modern Sharing copy; skipping."
27+ continue
28+ }
29+
1830 $IsOrganizer = $true
1931 Write-Host - ForegroundColor Green " IsOrganizer: [$IsOrganizer ]"
2032 return $IsOrganizer
@@ -24,6 +36,203 @@ function SetIsOrganizer {
2436 return $IsOrganizer
2537}
2638
39+ <#
40+ . SYNOPSIS
41+ Returns $true when the raw CalLog's From / Sender / SenderEmailAddress resolves to the
42+ supplied Identity (SMTP address). Used to confirm that an Organizer-typed row really
43+ belongs to the user being analyzed, and not a Modern-Sharing copy of someone else's data.
44+ #>
45+ function Test-CalLogFromMatchesIdentity {
46+ param (
47+ $CalLog ,
48+ [string ] $Identity
49+ )
50+
51+ if ([string ]::IsNullOrEmpty($Identity )) {
52+ return $false
53+ }
54+
55+ $candidates = @ ()
56+ if ($null -ne $CalLog.From ) { $candidates += [string ]$CalLog.From }
57+ if ($null -ne $CalLog.Sender ) { $candidates += [string ]$CalLog.Sender }
58+ if ($null -ne $CalLog.SenderEmailAddress ) { $candidates += [string ]$CalLog.SenderEmailAddress }
59+
60+ $identityLocal = ($Identity -split ' @' )[0 ]
61+
62+ foreach ($candidate in $candidates ) {
63+ if ([string ]::IsNullOrEmpty($candidate )) { continue }
64+
65+ # Try the resolved SMTP first (requires MailboxList to be populated by BuildCSV).
66+ if ($null -ne $script :MailboxList -and $script :MailboxList.Count -gt 0 ) {
67+ $resolved = GetSMTPAddress $candidate
68+ if (-not [string ]::IsNullOrEmpty($resolved ) -and
69+ [string ]::Equals($resolved.Trim (), $Identity.Trim (), [System.StringComparison ]::OrdinalIgnoreCase)) {
70+ return $true
71+ }
72+ }
73+
74+ # Embedded SMTP form: '"Name" <user@contoso.com>'
75+ if ($candidate -match ' <([^>]+@[^>]+)>' ) {
76+ if ([string ]::Equals($Matches [1 ].Trim(), $Identity.Trim (), [System.StringComparison ]::OrdinalIgnoreCase)) {
77+ return $true
78+ }
79+ }
80+
81+ # Direct SMTP match
82+ if ([string ]::Equals($candidate.Trim (), $Identity.Trim (), [System.StringComparison ]::OrdinalIgnoreCase)) {
83+ return $true
84+ }
85+
86+ # Last-resort: the CN often contains the SMTP local-part as a hint.
87+ if (-not [string ]::IsNullOrEmpty($identityLocal ) -and
88+ $candidate -match [regex ]::Escape($identityLocal )) {
89+ return $true
90+ }
91+ }
92+
93+ return $false
94+ }
95+
96+ <#
97+ . SYNOPSIS
98+ Classifies the user being analyzed as a Delegate-of-Organizer, Delegate-of-Attendee,
99+ or Attendee based on the Enhanced CalLogs.
100+
101+ A user is a Delegate when their logs contain a Modern Sharing copy of another mailbox's
102+ calendar entries (SharedFolderName != 'Not Shared') AND they either (a) take changes on
103+ those shared rows themselves, or (b) receive Resp.* messages on behalf of the shared
104+ mailbox owner.
105+
106+ The DelegateForSmtp output is the SMTP of the mailbox the user is delegating for.
107+ When the meeting Organizer's SMTP matches DelegateForSmtp the user is a Delegate of the
108+ Organizer; otherwise the user is a Delegate of some Attendee.
109+
110+ This function must be called after BuildCSV has populated $script:EnhancedCalLogs and
111+ the SMTP lookup caches.
112+ #>
113+ function SetDelegateRole {
114+ param (
115+ [array ] $EnhancedCalLogs ,
116+ [string ] $Identity
117+ )
118+
119+ $script :IsDelegateOfOrganizer = $false
120+ $script :IsDelegateOfAttendee = $false
121+ $script :DelegateForSmtp = $null
122+
123+ if ($null -eq $EnhancedCalLogs -or $EnhancedCalLogs.Count -eq 0 ) {
124+ return
125+ }
126+
127+ if ($script :IsOrganizer -or $script :IsRoomMB ) {
128+ return
129+ }
130+
131+ [array ] $sharedRows = $EnhancedCalLogs | Where-Object { $_.SharedFolderName -ne ' Not Shared' }
132+ if ($sharedRows.Count -eq 0 ) {
133+ Write-Verbose " SetDelegateRole: No Modern Sharing rows for [$Identity ]."
134+ return
135+ }
136+
137+ # Determine whether the user takes action on the shared rows themselves.
138+ [array ] $userChangesOnShared = $sharedRows | Where-Object {
139+ $_.TriggerAction -in @ (' Create' , ' Update' , ' SoftDelete' , ' MoveToDeletedItems' , ' HardDelete' ) -and
140+ (Test-ResponsibleUserMatchesIdentity - ResponsibleUser $_.ResponsibleUser - SenderSmtp $_.Sender - Identity $Identity )
141+ }
142+
143+ # Resp.* rows that arrived inside the shared folder are the "received all Resp's" signal.
144+ [array ] $sharedRespRows = $sharedRows | Where-Object { $_.ItemClass -like ' Resp.*' }
145+
146+ if ($userChangesOnShared.Count -eq 0 -and $sharedRespRows.Count -eq 0 ) {
147+ Write-Verbose " SetDelegateRole: User [$Identity ] has Modern Sharing rows but no delegate activity."
148+ return
149+ }
150+
151+ # Identify the mailbox the user is delegating for. ReceivedRepresenting is the strongest
152+ # signal because it's set by transport when mail is delivered on behalf of someone else.
153+ [array ] $representingValues = $sharedRows |
154+ Where-Object {
155+ -not [string ]::IsNullOrEmpty($_.ReceivedRepresenting ) -and
156+ $_.ReceivedRepresenting -ne ' -' -and
157+ $_.ReceivedRepresenting -ne ' NotFound'
158+ } |
159+ ForEach-Object { ([string ]$_.ReceivedRepresenting ).Trim() }
160+
161+ if ($representingValues.Count -gt 0 ) {
162+ $script :DelegateForSmtp = ($representingValues | Group-Object | Sort-Object Count - Descending | Select-Object - First 1 ).Name
163+ }
164+
165+ # Determine the meeting Organizer's SMTP (most common From across all appointment rows).
166+ $organizerSmtp = Get-MeetingOrganizerSmtp - EnhancedCalLogs $EnhancedCalLogs
167+
168+ # Fall back to the Organizer SMTP if ReceivedRepresenting was not informative.
169+ if ([string ]::IsNullOrEmpty($script :DelegateForSmtp )) {
170+ $script :DelegateForSmtp = $organizerSmtp
171+ }
172+
173+ if (-not [string ]::IsNullOrEmpty($organizerSmtp ) -and
174+ -not [string ]::IsNullOrEmpty($script :DelegateForSmtp ) -and
175+ [string ]::Equals($script :DelegateForSmtp , $organizerSmtp , [System.StringComparison ]::OrdinalIgnoreCase)) {
176+ $script :IsDelegateOfOrganizer = $true
177+ Write-Host - ForegroundColor Magenta " IsDelegateOfOrganizer: [$Identity ] is acting as a Delegate of the Organizer [$ ( $script :DelegateForSmtp ) ]."
178+ } else {
179+ $script :IsDelegateOfAttendee = $true
180+ Write-Host - ForegroundColor Magenta " IsDelegateOfAttendee: [$Identity ] is acting as a Delegate of the Attendee [$ ( $script :DelegateForSmtp ) ]."
181+ }
182+ }
183+
184+ <#
185+ . SYNOPSIS
186+ Compares a ResponsibleUser / Sender SMTP value (from EnhancedCalLogs) to the Identity.
187+ #>
188+ function Test-ResponsibleUserMatchesIdentity {
189+ param (
190+ [string ] $ResponsibleUser ,
191+ [string ] $SenderSmtp ,
192+ [string ] $Identity
193+ )
194+
195+ if ([string ]::IsNullOrEmpty($Identity )) { return $false }
196+
197+ foreach ($value in @ ($ResponsibleUser , $SenderSmtp )) {
198+ if ([string ]::IsNullOrEmpty($value ) -or $value -eq ' -' -or $value -eq ' NotFound' ) { continue }
199+ if ([string ]::Equals($value.Trim (), $Identity.Trim (), [System.StringComparison ]::OrdinalIgnoreCase)) {
200+ return $true
201+ }
202+ }
203+ return $false
204+ }
205+
206+ <#
207+ . SYNOPSIS
208+ Returns the most-common From SMTP across appointment rows in the Enhanced CalLogs, which
209+ is treated as the meeting Organizer's SMTP.
210+ #>
211+ function Get-MeetingOrganizerSmtp {
212+ param (
213+ [array ] $EnhancedCalLogs
214+ )
215+
216+ if ($null -eq $EnhancedCalLogs -or $EnhancedCalLogs.Count -eq 0 ) {
217+ return $null
218+ }
219+
220+ [array ] $appointmentFrom = $EnhancedCalLogs |
221+ Where-Object {
222+ ($_.ItemClass -eq ' Ipm.Appointment' -or $_.ItemClass -like ' Exception*' ) -and
223+ -not [string ]::IsNullOrEmpty($_.From ) -and
224+ $_.From -ne ' -' -and
225+ $_.From -ne ' NotFound'
226+ } |
227+ ForEach-Object { ([string ]$_.From ).Trim() }
228+
229+ if ($appointmentFrom.Count -eq 0 ) {
230+ return $null
231+ }
232+
233+ return ($appointmentFrom | Group-Object | Sort-Object Count - Descending | Select-Object - First 1 ).Name
234+ }
235+
27236<#
28237. SYNOPSIS
29238Checks if a set of Calendar Logs is from a Resource Mailbox.
0 commit comments