<#
.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."
}
}