-
Notifications
You must be signed in to change notification settings - Fork 132
/
Graph_Remove_meeting.ps1
373 lines (319 loc) · 17.9 KB
/
Graph_Remove_meeting.ps1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
#Requires -Version 3.0
# Make sure to fill in all the required variables before running the script
# Also make sure the AppID used corresponds to an app with sufficient permissions, as follows:
# Calendars.ReadWrite #Needed to fetch matching calendar events and remove them
# Exchange.ManageAsApp #Needed for the Exchange Online REST API calls
# The app should also be granted Exchange Online role. The default View-Only Recipients role should suffice, or Global Reader/Reports reader on Entra side.
#For details on what the script does and how to run it, check: https://www.michev.info/blog/post/6300/how-to-remove-meetings-from-all-microsoft-365-mailboxes-via-the-graph-api
[CmdletBinding(SupportsShouldProcess)] #Make sure we can use -WhatIf and -Verbose
Param(
[switch]$Quiet, #Suppress all output
[PSCustomObject]$MeetingObject, #Event object to process
[ValidateNotNullOrEmpty()][String]$MeetingId, #The ID of the meeting to remove, must be passed along with at least one mailbox to process
[ValidateNotNullOrEmpty()][String[]]$IncludeMailboxes, #Additional mailboxes to process, not necesarily listed as attendees. Also works with DGs :)
[ValidateNotNullOrEmpty()][String[]]$ExcludeMailboxes, #Do not process the following mailboxes
[switch]$ProcessAllMailboxes #Process all mailboxes
)
function Renew-Token {
param(
[ValidateNotNullOrEmpty()][string]$Service
)
#prepare the request
$url = 'https://login.microsoftonline.com/' + $tenantId + '/oauth2/v2.0/token'
#Define the scope based on the service value provided
if (!$Service -or $Service -eq "Graph") { $Scope = "https://graph.microsoft.com/.default" }
elseif ($Service -eq "Exchange") { $Scope = "https://outlook.office365.com/.default" }
else { Write-Error "Invalid service specified, aborting..." -ErrorAction Stop; return }
$Scopes = New-Object System.Collections.Generic.List[string]
$Scopes.Add($Scope)
$body = @{
grant_type = "client_credentials"
client_id = $appID
client_secret = $client_secret
scope = $Scopes
}
try {
$authenticationResult = Invoke-WebRequest -Method Post -Uri $url -Body $body -ErrorAction Stop -Verbose:$false
$token = ($authenticationResult.Content | ConvertFrom-Json).access_token
}
catch { throw $_ }
if (!$token) { Write-Error "Failed to aquire token!" -ErrorAction Stop; return }
else {
Write-Verbose "Successfully acquired Access Token for $service"
#Use the access token to set the authentication header
if (!$Service -or $Service -eq "Graph") { Set-Variable -Name authHeaderGraph -Scope Global -Value @{'Authorization'="Bearer $token";'Content-Type'='application/json'} -Confirm:$false -WhatIf:$false }
elseif ($Service -eq "Exchange") {
Set-Variable -Name authHeaderExchange -Scope Global -Value @{'Authorization'="Bearer $token";'Content-Type'='application/json'} -Confirm:$false -WhatIf:$false
#Add additional headers for Exchange
$authHeaderExchange["X-ResponseFormat"] = "json"
$authHeaderExchange["Prefer"] = "odata.maxpagesize=1000"
$authHeaderExchange["connection-id"] = $([guid]::NewGuid().Guid).ToString()
$authHeaderExchange["X-AnchorMailbox"] = "UPN:SystemMailbox{bb558c35-97f1-4cb9-8ff7-d53741dc928c}@$($TenantID)"
}
else { Write-Error "Invalid service specified, aborting..." -ErrorAction Stop; return }
}
}
function Check-ExORecipient {
param(
[Parameter(Mandatory=$true)][string]$Identity
)
#Use the REST endpoint
$uri = "https://outlook.office365.com/adminapi/beta/$($TenantID)/Recipient(`'$Identity`')"
try {
$result = Invoke-WebRequest -Method Get -Uri $uri -Headers $authHeaderExchange -Verbose:$false -ErrorAction Stop #suppress the output
}
catch {
Write-Verbose "Recipient not found: $Identity"
return
}
return ($result.Content | ConvertFrom-Json)
}
function Get-AllMailboxes {
#Use the REST endpoint
$rMailboxes = @()
$uri = "https://outlook.office365.com/adminapi/beta/$($TenantID)/Mailbox?RecipientTypeDetails=UserMailbox,SharedMailbox,RoomMailbox,EquipmentMailbox&`$top=1000"
do {
$result = Invoke-WebRequest -Method Get -Uri $uri -Headers $authHeaderExchange -Verbose:$false -ErrorAction Stop #suppress the output
$result = ($result.Content | ConvertFrom-Json)
$uri = $result.'@odata.nextLink'
$rMailboxes += $result.Value #this will fail if we only get a single mailbox
} while ($uri)
if (!$rMailboxes -or ($rMailboxes.Count -eq 0)) { Write-Error "No mailboxes found, aborting..." -ErrorAction Stop; return }
foreach ($r in $rMailboxes) {
$rInfo = [ordered]@{
Email = $r.PrimarySmtpAddress
DisplayName = $r.DisplayName
RecipientType = $r.RecipientTypeDetails
ObjectId = $r.ExternalDirectoryObjectId
}
$Mailboxes[$r.PrimarySmtpAddress] = $rInfo
}
#return ($Mailboxes | Select-Object -Property PrimarySmtpAddress,ExternalDirectoryObjectId)
}
function Get-DGMember {
param(
[Parameter(Mandatory=$true)][string]$Identity
)
$body = @{
CmdletInput = @{
CmdletName="Get-DistributionGroupMember"
Parameters=@{"Identity"=$Identity}
}
}
$uri = "https://outlook.office365.com/adminapi/beta/$($TenantID)/InvokeCommand?`$select=PrimarySmtpAddress,DisplayName,RecipientTypeDetails,ExternalDirectoryObjectId"
try {
$result = Invoke-WebRequest -Method POST -Uri $uri -Headers $authHeaderExchange -Body ($body | ConvertTo-Json -Depth 5) -ContentType "application/json" -Verbose:$false -ErrorAction Stop #suppress the output
}
catch {
Write-Verbose "Group not found: $Identity"
return
}
return ($result.Content | ConvertFrom-Json).value
}
function Get-UGMember {
param(
[Parameter(Mandatory=$true)][string]$Identity
)
$body = @{
CmdletInput = @{
CmdletName="Get-UnifiedGroupLinks"
Parameters=@{"Identity"=$Identity;"LinkType"="Member"}
}
}
$uri = "https://outlook.office365.com/adminapi/beta/$($TenantID)/InvokeCommand?`$select=PrimarySmtpAddress"
try {
$result = Invoke-WebRequest -Method POST -Uri $uri -Headers $authHeaderExchange -Body ($body | ConvertTo-Json -Depth 5) -ContentType "application/json" -Verbose:$false -ErrorAction Stop #suppress the output
}
catch {
Write-Verbose "Group not found: $Identity"
return
}
return ($result.Content | ConvertFrom-Json).value.PrimarySmtpAddress
}
#Check each attendee entry and resolve it to unique recipient. Needed because Attendees property can contain alias instead of email address.
#Also needed to handle distribution groups and unified group - expand members and process each.
function Process-Attendees {
param(
[Parameter(Mandatory=$true)][ValidateNotNull()]$Attendees,
[string[]]$ExcludeAttendees
)
foreach ($attendee in $Attendees) {
#If we pass input from the helper functions, it's a proper object
if ($attendee.PrimarySmtpAddress) { $email = $attendee.PrimarySmtpAddress }
else { $email = $attendee }
if ($ExcludeAttendees -and ($ExcludeAttendees -contains $email)) { continue } #Skip if excluded
if ($Mailboxes[$email]) { continue } #Skip if already processed
try {
if ($attendee.RecipientTypeDetails) { $r = $attendee } #Skip if we're passing an object, we already have all the info we need
else {
$r = Check-ExORecipient -Identity $email -ErrorAction Stop -Verbose:$VerbosePreference
if (($r.count -gt 1) -or ($Mailboxes.Values.Email -contains $r.PrimarySmtpAddress)) { continue } #Skip if multiple results or duplicate email
}
if (($r.RecipientTypeDetails -eq 'MailUniversalDistributionGroup') -or ($r.RecipientTypeDetails -eq 'MailUniversalSecurityGroup')) { #Expand distribution groups
$rList = Get-DGMember -Identity $r.PrimarySmtpAddress -ErrorAction Stop
Process-Attendees -Attendees $rList
}
elseif ($r.RecipientTypeDetails -eq 'GroupMailbox') { #Expand Unified groups
$rList = Get-UGMember -Identity $r.PrimarySmtpAddress -ErrorAction Stop
Process-Attendees -Attendees $rList
}
elseif ($r.RecipientTypeDetails -in @('UserMailbox','SharedMailbox','RoomMailbox','EquipmentMailbox')) {
$rInfo = [ordered]@{
Email = $r.PrimarySmtpAddress
DisplayName = $r.DisplayName
RecipientType = $r.RecipientTypeDetails
ObjectId = $r.ExternalDirectoryObjectId
}
$Mailboxes[$email] = $rInfo
}
else { continue } #not covering any other recipient type
}
catch {
continue #skip on any error
}
}
}
function Find-Event {
param(
[Parameter(Mandatory=$true,ParameterSetName="ById")][Parameter(Mandatory=$true,ParameterSetName="NotById")][ValidateNotNullOrEmpty()][string]$Mailbox,
[Parameter(Mandatory=$false,ParameterSetName="ById")][ValidateNotNullOrEmpty()][string]$MeetingId, #EWSId, can copy it from OWA
[Parameter(Mandatory=$false,ParameterSetName="ById")][ValidateNotNullOrEmpty()][string]$MeetingUid, #UID
[Parameter(Mandatory=$false,ParameterSetName="NotById")][string]$Subject,
[Parameter(Mandatory=$false,ParameterSetName="NotById")][datetime]$StartDate,
[Parameter(Mandatory=$false,ParameterSetName="NotById")][datetime]$EndDate
)
if ($MeetingId) {
if (!$MeetingId.StartsWith("AAMkAG")) { Write-Error "Invalid ID value provided, aborting..." -ErrorAction Stop; return }
#Works with URLEncoded values, too
$uri = "https://graph.microsoft.com/beta/users/$Mailbox/events/$($MeetingId)?`$select=id,uid,createdDateTime,subject,isCancelled,start,end,isOrganizer,type,attendees,organizer"
try {
$res = Invoke-WebRequest -Uri $uri -Headers $authHeaderGraph -Method Get -ErrorAction Stop -Verbose:$false
$events = $res.Content | ConvertFrom-Json
}
catch { Write-Error "Failed to fetch events, aborting..." -ErrorAction Stop; return }
}
else {#Else we use filter
$filter = @()
if ($MeetingUid) {
if (!$MeetingUId.StartsWith("040000008200E00074C5B7101A82E008")) { Write-Error "Invalid UID value provided, aborting..." -ErrorAction Stop; return }
$filter = "uid eq '$MeetingUid'"
}
else {
if ($StartDate -xor $EndDate) { Write-Error "Both StartDate and EndDate must be provided" -ErrorAction Stop; return }
if ($StartDate -or $EndDate) { $filter += "start/dateTime ge '$StartDate' and end/dateTime le '$EndDate'" }
if ($Subject) {
if ([uri]::unEscapeDataString($Subject) -ne $Subject) { $filter += "subject eq '$Subject'" }
else { $filter += "subject eq '$([uri]::EscapeDataString($Subject))'" }
}
}
$filter = $filter -join " and "
try {
#Use /BETA here as /V1.0 does not return the uid on $select... WTF Microsoft?!
$uri = "https://graph.microsoft.com/beta/users/$Mailbox/events?`$filter=$filter&`$top=100&`$orderby=start/dateTime&`$select=id,uid,createdDateTime,subject,isCancelled,start,end,isOrganizer,type,attendees,organizer"
$res = Invoke-WebRequest -Uri $uri -Headers $authHeaderGraph -Method Get -ErrorAction Stop -Verbose:$false
$events = ($res.Content | ConvertFrom-Json).Value
}
catch { Write-Error "Failed to fetch events, aborting..." -ErrorAction Stop; return }
}
if ($events.count -gt 1) {
Write-Warning "Multiple events found, please select the one to process:"
$objEvent = $events | Out-GridView -Title "Select event to process" -OutputMode Single
if (!$objEvent) { Write-Error "No event selected, aborting..." -ErrorAction Stop; return }
}
elseif ($events.count -eq 0) { Write-Warning "No events found, please specify different criteria"; return }
else { $objEvent = $events[0] }
#We need the UID to process matching events/instances, so abort if empty
if (!$objEvent.uid) { Write-Error "Null UID returned, aborting..." -ErrorAction Stop } #This should not happen
return $objEvent
}
#==========================================================================
#Main script starts here
#==========================================================================
#Variables to configure
$tenantID = "tenant.onmicrosoft.com" #Your tenant root domain. Please do not use a GUID instead, as we use the value for the X-AnchorMailbox header
$appID = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" #the GUID of your app. For best result, use app with Sites.ReadWrite.All scope granted.
$client_secret = "verylongsecurestring" #client secret for the app
Renew-Token -Service "Graph"
Renew-Token -Service "Exchange"
if (!$PSBoundParameters.Count) { return } #Useful when dot-sourcing the script
$Mailboxes = @{}
#Check the input parameters to get the list of mailboxes to process
if ($MeetingObject -and $MeetingObject.attendees) { $IncludeMailboxes += ($MeetingObject.attendees.emailAddress | select -ExpandProperty address) }
if (!$IncludeMailboxes -and !$ProcessAllMailboxes) {
Write-Error "No mailboxes provided, aborting..." -ErrorAction Stop; return
}
if ($ProcessAllMailboxes) { Get-AllMailboxes }
else {
$IncludeMailboxes += ($MeetingObject.attendees.emailAddress | select -ExpandProperty address)
if ($ExcludeMailboxes) {
Process-Attendees $IncludeMailboxes -ExcludeAttendees $ExcludeMailboxes
}
else { Process-Attendees $IncludeMailboxes }
}
if (!$Mailboxes -or $Mailboxes.Count -eq 0) { Write-Error "No mailboxes found, aborting..." -ErrorAction Stop; return }
Write-Verbose "Processing a total of $($Mailboxes.Count) mailboxes provided via input parameters"
#If MeetingObject was passed, we can skip the search
if ($MeetingObject) { $objEvent = $MeetingObject }
else { #Else we need to fetch one event instance first in order to get the attendee list
if (!$MeetingId) { Write-Error "No meeting ID provided, aborting..." -ErrorAction Stop; return }
$eventFound = $false
foreach ($Mbox in $Mailboxes.GetEnumerator()) {#Loop over the set of mailboxes
if ($eventFound) { continue } #Skip if already found a match
#Use /BETA here as /V1.0 does not return the uid on $select... WTF Microsoft?!
$objEvent = Find-Event -Mailbox $Mbox.Value.ObjectId -MeetingUid $MeetingId
if ($objEvent) {
Write-Verbose "Found matching event in mailbox: $($Mbox.Value.Email)"
$eventFound = $true
break
}
}
if (!$objEvent) { Write-Error "No matching event found, aborting..." -ErrorAction Stop; return }
#We now have an event, add any attendees not already in the mailboxes list. Also expand distribution group and unified group membership.
if (!$objEvent.attendees) { Write-Verbose "No attendees found in event object, processing only the mailboxes provided with input" }
else {
Write-Verbose "Processing attendees found in the event object"
Process-Attendees ($objEvent.attendees.emailAddress | select -ExpandProperty address) -ExcludeAttendees $ExcludeMailboxes
Write-Verbose "Processing event removal for a total of $($Mailboxes.Count) mailboxes after expanding attendees list"
}
}
$output = @()
#Loop over the set of mailboxes to remove the event
foreach ($Mbox in $Mailboxes.GetEnumerator()) {
Write-Verbose "Processing mailbox: $($Mbox.Value.Email)"
#Find event with matching UID in the mailbox #Maybe leverage Find-Event here?
try {
$uri = "https://graph.microsoft.com/beta/users/$($Mbox.Value.ObjectId)/events?`$filter=uid eq '$($objEvent.uid)'&`$top=1&`$select=id,uid"
$res = Invoke-WebRequest -Uri $uri -Headers $authHeaderGraph -Method Get -ErrorAction Stop -Verbose:$false
$cEvent = ($res.Content | ConvertFrom-Json).Value
}
catch { Write-Verbose $_.Exception.Message; $cEvent | Out-Default; continue } #Move to next mailbox if we fail to fetch events
if (!$cEvent) {
Write-Warning "No matching event found in mailbox: $($Mbox.Value.Email)"
$output += @{"User" = $Mbox.Value.Email;"Result" = "NotFound"}
continue
}
else {
Write-Verbose "Found matching event in mailbox: $($Mbox.Value.Email), processing removal..."
if ($PSCmdlet.ShouldProcess($($Mbox.Value.Email),"Remove event: '$($objEvent.subject)'")) {
$uri = "https://graph.microsoft.com/v1.0/users/$($Mbox.Value.ObjectId)/events/$($cEvent.id)"
try {
#Maybe add check for organizer and skip if the current user is the organizer?
Invoke-WebRequest -Method Delete -Uri $uri -Headers $authHeaderGraph -SkipHeaderValidation -Verbose:$false -ErrorAction Stop | Out-Null #suppress the output
Write-Verbose "Successfully removed event from mailbox: $($Mbox.Value.Email)"
$output += @{"User" = $Mbox.Value.Email;"Result" = "Success"}
}
catch {
Write-Verbose "Failed to remove event from mailbox: $($Mbox.Value.Email)"
Write-Verbose $_.Exception.Message
$output += @{"User" = $Mbox.Value.Email;"Result" = "Failure"}
continue
}
}
else {
Write-Verbose "Skipped removal of event from mailbox: $($Mbox.Value.Email)"
$output += @{"User" = $Mbox.Value.Email;"Result" = "Skipped"}
}
}
}
if (!$Quiet -and !$WhatIfPreference) { $output | select User, Result } #Write output to the console unless the -Quiet parameter is used
$output | select User, Result | Export-Csv -Path "$($PWD)\$((Get-Date).ToString('yyyy-MM-dd_HH-mm-ss'))_MeetingRemoval_$($objEvent.uid).csv" -NoTypeInformation -Encoding UTF8 -UseCulture -Confirm:$false -WhatIf:$false