Articles in the "Meeting cancellation script" series
- Cancel future meetings in a mailbox [This article]
- Meeting cancellation script updated
- Meeting cancellation script updated for large result sets
One of the long-running woes of the Exchange admin is that you can’t transfer “ownership” of a meeting to someone else when the owner leaves the company. While you still can’t do that, Microsoft recently announced the Remove-CalendarEvents cmdlet for mailboxes in Exchange Online. Its intention is to allow the administrator to cancel all future meetings for a terminated employee so that someone else can create new meetings in their place.
Because it works only for cloud mailboxes, I wrote a script to do the same thing for on-premises mailboxes (though it will also work for cloud mailboxes). It uses EWS to get all future meetings for a mailbox and cancel them. The default settings are to cancel all meetings that occur in the next year where the mailbox is the organizer. You can also choose to have it cancel (decline) meetings where the mailbox is an attendee, and you can specify text that should be added to the cancellation or decline message. Additionally, if you don’t want to wholly cancel recurring meetings because the history of all those occurrences will be lost for the attendees, you can instead have organized recurring meetings end so that only future occurrences are canceled, but historical occurrences remain.
Because of the way calendar items are stored, you can get an item and then see when it occurs, if it is recurring, etc., but if it is a recurring meeting you only see the first occurrence. To work with occurrences, you use a calendar view (time frame) and let Exchange worry about whether an occurrence of a recurring item should be included in the window. But you can have multiple occurrences of the same meeting in the time frame, and you want to delete the series, not an occurrence. In a calendar view, you can see if a meeting is an occurrence and, if so, get the corresponding master for that series.
Once the series is canceled (or ended, if you choose that option), there still can be more occurrences of that canceled series in the search results. If you try and get the recurring master for an occurrence whose master has already been canceled, you’ll get an error. So, these occurrences can be skipped, but you have to know which ones can be skipped. To solve that, I store the recurring meeting’s global object ID (which is the same for a recurring master and all of its occurrences because it is really just one meeting as stored in the calendar) in an array. For every meeting in the search results that is an occurrence, I check if the global object ID is in the array. If so, I skip it because its master has already been deleted. If not, I add the global object ID to the array, then cancel the meeting.
The script has comment-based help so you know what all the parameters are for, and there are inline comments so you can see what is being done throughout. While you can use current credentials or specify one, if you want it to prompt if you don’t specify credentials and don’t want to use default credentials, you can comment and uncomment the the necessary lines at 81-84. Likewise for the EWS URL, it is hard-coded to EXO, which you can change, but if you want to use autodiscover, you can comment and uncomment the necessary lines at 91-93.
Download the script via the link below, but you can see the code and copy it below, too.
Cancel-MailboxMeetings.ps1 (12.7 KiB)
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 |
<# .Synopsis Cancel meetings within a specified timeframe for a mailbox .Description Ideal for terminated employees, this script will cancel all meetings organized by the mailbox, and optionally where the mailbox is an attendee, within a given timeframe. The default is one year from run time. .Parameter EmailAddress Email address of the mailbox to process. Pipeline support allows piping from Get-Mailbox. .Parameter QueryStartDate Start date of the timeframe to look for meetings to cancel. Default is now. .Parameter QueryWindowInDays Number of days after the start date to look for meetings to cancel. Default is 365 days. .Parameter IncludeAttendeeMeetings Switch to also cancel meetings where the mailbox is an attendee .Parameter AttendeeMeetingsOnly Switch to cancel meetings only where the mailbox is an attendee .Parameter EndOrganizedRecurringMeetings Switch to end organized recurring meetings instead of canceling them. The end date of the series is set to the cmdlet's StartDate. This will leave past meeting occurrences on attendee calendars. .Parameter SuppressLostExceptionPrompt Switch to silently acknowledge that past occurrences of series that have been modified will be deleted if EndOrganizedRecurringMeetings is used .Parameter PreviewOnly Switch to display the meeting subjects and dates that would be canceled .Parameter CancellationText Text to add to the body of the cancellation message .Parameter Credential Optional credential object to use instead of asking for or using current credentials .Parameter UseImpersonation Switch parameter to use impersonation instead of FMA or folder permission .Example Cancel-MailboxMeetings.ps1 -EmailAdress johndoe@company.com .Example Cancel-MailboxMeetings.ps1 janedoe@company.com "6/1/17" "8/1/17" -IncludeAttendeeMeetings -CancellationText "Jane is no longer here." .Example Get-Mailbox johndoe | .\Cancel-MailboxMeetings.ps1 -Credential (Get-Credential) -EndOrganizedRecurringMeetings .Notes Required permission: *Editor role to calendar will work for meetings (attendee or organizer) - or - *Impersonation right to the target mailbox - or - *Full mailbox access; Send As is not required Version: 1.4.3 Date: 10/25/18 #> #Version 3 is only required because of use of the [pscredential] type accelerator for the Credential parameter #requires -Version 3 [CmdletBinding(SupportsShouldProcess=$true,ConfirmImpact='High',DefaultParameterSetName='IncludeAttendee')] Param ( [parameter(Position=0,Mandatory=$true,ValueFromPipelineByPropertyName=$true,HelpMessage='Email address of user')] [Alias('PrimarySMTPAddress')][string]$EmailAddress, [parameter(Position=1,Mandatory=$false)][DateTime]$QueryStartDate = (Get-Date), [parameter(Position=2,Mandatory=$false)][int]$QueryWindowInDays = 365, [parameter(ParameterSetName='IncludeAttendee')][switch]$IncludeAttendeeMeetings, [parameter(ParameterSetName='AttendeeOnly')][switch]$AttendeeMeetingsOnly, [switch]$EndOrganizedRecurringMeetings, [switch]$SuppressLostExceptionPrompt, [switch]$PreviewOnly, [string]$CancellationText, [PSCredential]$Credential, [switch]$UseImpersonation ) begin { #Check for EWS API $apiPath = (($(Get-ItemProperty -ErrorAction SilentlyContinue -Path Registry::$(Get-ChildItem -ErrorAction SilentlyContinue -Path "Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Exchange\Web Services" | Sort-Object Name -Descending | Select-Object -First 1 -ExpandProperty Name))."Install Directory") + "Microsoft.Exchange.WebServices.dll") if (Test-Path $apiPath) { Add-Type -Path $apiPath } else { Write-Error "The Exchange Web Services Managed API is required to use this script." -Category NotInstalled break } $exchangeVersion = [Microsoft.Exchange.WebServices.Data.ExchangeVersion]::Exchange2010_SP2 $exchangeService = New-Object -TypeName Microsoft.Exchange.WebServices.Data.ExchangeService($exchangeVersion) } process { #region Functions function Process-Cancellation ($meeting, $attendeeType, $goid) { #Identify recurring meetings by AppointmentType enumeration because IsRecurring #property can incorrectly be True when it is not recurring if ($meeting.AppointmentType -ne [Microsoft.Exchange.WebServices.Data.AppointmentType]::Single) { #Skip processing if recurring master has already been canceled if ($meetingIds -notcontains $goid) { #Determine whether occurrence or master if ($meeting.AppointmentType -ne [Microsoft.Exchange.WebServices.Data.AppointmentType]::RecurringMaster) { #Get recurring master $master = [Microsoft.Exchange.Webservices.Data.Appointment]::BindToRecurringMaster($exchangeService,$meeting.Id) } else { $master = $meeting } #Add Global ID to array so remaining occurrences in collection can be ignored $meetingIds.Add($goid) if ($attendeeType -eq "Organizer") { #If ending instead of canceling, end only if first occurrence is in the past, else cancel if ($EndOrganizedRecurringMeetings -and ($StartDate -gt $master.Start)) { Write-Verbose "EndOrganizedRecurringMeetings is true and first occurrence is in past" #Check for modified exceptions that will be lost if ($master.ModifiedOccurrences) { Write-Verbose "Meeting series has modified occurrences" $hasException = $false foreach ($occurrenceId in $master.ModifiedOccurrences) { $propertySet = New-Object -TypeName Microsoft.Exchange.WebServices.Data.PropertySet( [Microsoft.Exchange.WebServices.Data.BasePropertySet]::IdOnly, @([Microsoft.Exchange.WebServices.Data.AppointmentSchema]::Start)) $occurrence = [Microsoft.Exchange.WebServices.Data.Item]::Bind($exchangeService,$occurrenceId.ItemId,$propertySet) if ($occurrence.Start -lt $StartDate) { Write-Verbose "Meeting series has modified occurrence in the past" $hasException = $true break } } } if ($hasException) { if ($PreviewOnly) { Update-MeetingEndDate -meeting $master } elseif ($PSCmdlet.ShouldProcess("Meeting series with the subject `"$($master.Subject)`" that has one or more past occurrence exceptions that will be lost if the series`' end date is changed") -or $SuppressLostExceptionPrompt) { Update-MeetingEndDate -meeting $master } } else { Update-MeetingEndDate -meeting $master } } else { Send-MeetingCancel -meeting $master 'Series' } } else { Send-MeetingDecline -meeting $master 'Series' } } } else #Non-recurring meeting { if ($attendeeType -eq "Organizer") { Send-MeetingCancel -meeting $meeting 'Meeting' } else { Send-MeetingDecline -meeting $meeting 'Meeting' } } } function Update-MeetingEndDate ($meeting) { if ($PreviewOnly) { Write-Output "Series whose end date would be updated: `"$($meeting.Subject)`" on $($meeting.Start.ToShortDateString())" } else { $meeting.Recurrence.EndDate = $StartDate $meeting.Update([Microsoft.Exchange.WebServices.Data.SendInvitationsMode]::SendToAllAndSaveCopy) Write-Output "Series end date updated: $($meeting.Subject)" } } function Send-MeetingCancel ($meeting, $type) { if ($PreviewOnly) { Write-Output "$type that would be canceled: `"$($meeting.Subject)`" on $($meeting.Start.ToShortDateString())" } else { [void]$meeting.CancelMeeting($CancellationText) Write-Output "$type canceled: $($meeting.Subject)" } } function Send-MeetingDecline ($meeting, $type) { if ($PreviewOnly) { Write-Output "$type that would be declined: `"$($meeting.Subject)`" on $($meeting.Start.ToShortDateString())" } else { #Decline method does not support custom message, so create decline message if ($CancellationText) { $declineMessage = $meeting.CreateDeclineMessage() $declineMessage.Body = $CancellationText [void]$declineMessage.Send() Write-Output "$type declined: $($meeting.Subject)" } else { [void]$meeting.Decline($true) Write-Output "$type declined: $($meeting.Subject)" } } } #endregion if (-not($Credential)) { #$Credential = Get-Credential #$exchangeService.Credentials = New-Object -TypeName Microsoft.Exchange.WebServices.Data.WebCredentials($Credential) # Or $exchangeService.UseDefaultCredentials = $true } else { $exchangeService.Credentials = New-Object -TypeName Microsoft.Exchange.WebServices.Data.WebCredentials($Credential) } #$exchangeService.AutodiscoverUrl($EmailAddress, {$true}) #Or use hard-coded URL $exchangeService.Url = "https://outlook.office365.com/EWS/Exchange.asmx" if ($UseImpersonation) { $exchangeService.ImpersonatedUserId = New-Object -TypeName Microsoft.Exchange.WebServices.Data.ImpersonatedUserId( [Microsoft.Exchange.WebServices.Data.ConnectingIdType]::SmtpAddress, $EmailAddress) } #Bind to the calendar $folderID = New-Object -TypeName Microsoft.Exchange.WebServices.Data.FolderId( [Microsoft.Exchange.WebServices.Data.WellKnownFolderName]::Calendar,$EmailAddress) $calFolder = [Microsoft.Exchange.WebServices.Data.Folder]::Bind($exchangeService,$folderID) $StartDate = $QueryStartDate $EndDate = $StartDate.AddDays($QueryWindowInDays) #List object to hold search results from loops $appointments = New-Object -TypeName System.Collections.Generic.List[Microsoft.Exchange.WebServices.Data.Appointment] #Use pseudo-paging to get all items in timeframe $searchLimit = 1000 $moreItems = $true while ($moreItems) { $calendarView = New-Object -TypeName Microsoft.Exchange.WebServices.Data.CalendarView($StartDate, $EndDate, $searchLimit) $properties = New-Object -TypeName Microsoft.Exchange.WebServices.Data.PropertySet( [Microsoft.Exchange.WebServices.Data.BasePropertySet]::FirstClassProperties) #Add Clean Global Object ID to property set so it can be used to correlate occurrences of the same meeting $propCleanGOID = New-Object -TypeName Microsoft.Exchange.WebServices.Data.ExtendedPropertyDefinition( [Microsoft.Exchange.WebServices.Data.DefaultExtendedPropertySet]::Meeting, 35, [Microsoft.Exchange.WebServices.Data.MapiPropertyType]::Binary) $properties.Add($propCleanGOID) $calendarView.PropertySet = $properties $searchResult = $exchangeService.FindAppointments($calFolder.Id,$calendarView) if ($searchResult.MoreAvailable) { #To mimic paging, set start of next loop to start of the last appointment in the search result #This causes duplicates in the results, which will be removed $StartDate = $searchResult.Items[$searchResult.Items.Count - 1].Start Write-Verbose -Message "Next loop will start at $StartDate." } else { $moreItems = $false } $appointments.AddRange($searchResult.Items) } if ($appointments.Count -gt 0) { Write-Verbose -Message "Number of appointment items: $($appointments.Count)" #Exclude appointments (non-meetings) and already canceled meetings $allMeetings = $appointments | Where-Object {$_.IsMeeting -and -not $_.IsCancelled} Write-Verbose -Message "Number of meeting items (with duplicates): $($allMeetings.Count)" #Remove duplicates caused by paging workaround $meetings = $allMeetings | Group-Object -Property Id | ForEach-Object {$_.Group | Select-Object -First 1} Write-Verbose -Message "Number of meeting items (duplicates removed): $($meetings.Count)" if ($meetings) { Write-Output "Processing $($meetings.Count) meeting occurrences." #Create array to hold list of recurring master IDs $meetingIds = New-Object -TypeName System.Collections.Generic.List[System.String] foreach ($item in $meetings) { #Get meeting global ID from extended properties collection $cleanGOID = $null [void]$item.TryGetProperty($propCleanGOID, [ref]$cleanGOID) if ($item.MyResponseType -eq [Microsoft.Exchange.WebServices.Data.MeetingResponseType]::Organizer) #Mailbox is organizer { if (-not($AttendeeMeetingsOnly)) { Process-Cancellation -meeting $item -attendeeType "Organizer" -goid $cleanGOID } } elseif ($IncludeAttendeeMeetings -or $AttendeeMeetingsOnly) #Decline meetings where mailbox is attendee { Process-Cancellation -meeting $item -attendeeType "Attendee" -goid $cleanGOID } } } else { Write-Output "There are no meetings in the specified timeframe." } } else { Write-Output "There are no meetings in the specified timeframe." } } |