How to remove meetings from all Microsoft 365 mailboxes via the Graph API

One common complication of “leaver” scenarios is handling meetings organized by the user, especially recurring ones. If the meetings are not cancelled before the user account is removed, you end up with “orphaned” entries, which can potentially be spread across multiple calendars and have prolonged duration. Some other scenarios where users might be missing for weeks or months at a time might also result in the need to cancel meetings organized by them. If this is not possible, you can consider removing the meetings instead. In this article we will examine how the Graph API can help address such scenarios and provide you with a “proof of concept” solution to remove meetings when the owner is no longer around.

Before we do that, I should warn you that this is NOT the preferred way to handle such scenarios. The preferred method is to cancel any such meetings, which in general is something only the organizer can do. Microsoft provided us with a “hack” to do this on behalf of the user, via the Remove-CalendarEvents cmdlet. The catch here is that the user account (and mailbox) has to be still present – you cannot use the cmdlet if the mailbox is no longer available. Another thing Microsoft has done to help minimize the impact of such scenarios is to enforce a scheduling window for recurring meetings, i.e. if the meeting has monthly recurrence, end it after an year or 13 instances.

Programmatically, we can leverage the EWS API and its impersonation capabilities to cancel meetings on behalf of the user, to much the same effect as the Remove-CalendarEvents cmdlet. We are again limited by the requirement to have the mailbox in active state however. If that’s not the case, the only thing we can do instead is delete any instances of the meeting, across a given set of mailboxes. But as Microsoft has repeatedly told us to not use EWS anymore and switch to the Graph API instead, we are going to leverage the latter for our “proof of concept” script.

What do we need in order to remove an event?

So how would such a solution look like? Well, we need to know which meetings(s) to delete, and getting an unique identifier is probably the most challenging part of the process. And, as the same event can exist in multiple mailboxes, we need a value that does not change when we iterate over mailboxes. The good news is that such property does exist, namely the uid one (or GlobalObjectId in other endpoints). The bad news is that its values look something like this:

040000008200E00074C5B7101A82E008000000006097EA606016DB010000000000000000100000009C0035723847D647AA9CE828E3EFD23E

Obviously, we cannot expect people to provide such values out of thin air, so we need a method to search events instead. We are in luck, as there are actually multiple ways we can go about this, even though some of them require additional work. In no particular other, here are some ways to search for events and returns the corresponding uid value:

  • Use the Graph API’s LIST events method, which allows you to filter based on startDate, endDate and/or subject. Some other properties are also supported for filtering, but unfortunately you cannot filter by the list of attendees or even the organizer‘s email address (you can filter by name though). Another quirk of the Graph API is that when using the $select operator, the uid property is NOT returned… unless you use the /beta endpoint.GraphDeleteEvents
  • Use the EWS API’s FindAppointments or FindItem operations. As we focus on the Graph API in this article, we will not be examining those, but there are plenty of code samples available online.
  • Use Exchange Online PowerShell. Two cmdlets can help here. While the Get-CalendarDiagnosticObjects cmdlet  is primarily intended for troubleshooting scenarios, it allows you to search for events based on some criteria, such as the start/end date, subject, or the actual event Ids. The important thing is that it does return the GlobalObjectId value, which we can use for our purposes.
    Get-CalendarDiagnosticObjects -Identity vasil -StartDate 11/16/2024 -EndDate 11/18/2024 -Subject "bla bla"

    Similarly, the (undocumented) Get-CalendarViewDiagnostics cmdlet can be used for the same purpose, albeit it only allows you to home in on events based on time range. As this cmdlet does not support search by subject, it is a bit harder to use, but does get the job done.

    Get-CalendarViewDiagnostics -Identity vasil -WindowStartUtc 10/16/2024 -WindowEndUtc 10/18/2024
  • On the client side, I am not aware of any method to expose the GlobalObjectId value for a meeting. Of course you can use low-level tools such as MFCMAPI, and there’s probably some add-in out there that can also help. The best you can get with out of the box functionality is to fetch the Id of the item via OWA: open the Calendar, select the event in question and “expand” it, then copy the URL from the nav bar. It will look something like this:
    https://outlook.office.com/calendar/item/AAMkAGRmNTUzMzFkLWM5ZGQtNGYyMS04ZmZhLWVhYmUxYjI3MTI2NQFRAAgI3Qj2RtfAAEYAAAAAMQapn3lC8E6hFsYhIcNgLwcAWART0wAAQky3C0HADwY1jwAAAAABDQAAWART0wAAQky3C0HADwY1jwAAAAEl2gAAEA%3D%3D

    where the value after the /item node is the URL-safe value of the event Id (EWSId). This value however is not immutable, but we can use it to fetch the matching event and its GlobalObjectId/uid value.

OK, now we know how to get a globally unique identifier for the event, which in turn we can use to find any matching entries and remove them. We also need to know which mailboxes to remove the event from. Sure, we can process all mailboxes, but in any organization of size this can take some time. A more targeted approach is preferable, and for that we either have to provide a list of mailboxes to process, or rely on the attendee information contained within the event object. The latter does sound like the cleanest option, but unfortunately we’re not always able to rely on this data.

To address these concerns, the script takes a “double” approach. First, it will fetch the attendee information (if it manages to find a matching event that is), and will actually proceed to expand any group membership as part of the process. It will then also process a list of “included” mailboxes, which you can provide via the corresponding parameter (more on parameters in a minute). And, to address scenarios where you absolutely must not touch a given mailbox, the script also provides supports for an “exclusion” list.

Once we have the event id and a list of mailboxes to process, the rest is easy. Check if the event exists, ask for confirmation (if required), delete the event, output some statistics. The details are in the next section.

Anatomy of the script

In this section we will discuss the actual implementation details. As with most scripts I produce, we have a set of parameters (to be covered in the next section), a set of helper functions and the “main” part of the script. The nature of the task at hand dictates the choice of authentication – we want to use application permissions and the client credentials flow. Assuming the relevant variables are configured, the first thing the script will do is obtain two access tokens, one for the Graph API and one for Exchange Online’s REST API.

We will use the latter throughout the script to resolve and verify email addresses and other identifiers, as not every value we find as part of the attendees collection is an actual mailbox, or a recipient internal to the organization. After all, we can only delete events from mailboxes we have access to. In order to facilitate those checks, few helper functions are introduced: one to verify whether valid recipient exists for a given entry, one to fetch all mailboxes, functions to fetch Distribution group and Microsoft 365 group members, and a “workhorse” function that leverages all aforementioned functions to “process” attendee entries and return the list of mailbox objects we will perform the delete operation against.

Fair warning – all these functions are very simple, so do not expect wonders here. For example, no attempt is made to cover membership of dynamic groups, even though we have some options to do so in both the Graph API and ExO REST API. Only basic validation of input values is performed, and some checks for URL-safe strings. Still, the functions help translate the list of attendees to a list of mailboxes to take action against, as used by the rest of the script. As a “fallback” scenario, you can leverage the –ProcessAllMailboxes switch, to cover all mailboxes within the organization.

Moving on, after obtaining the initial list of mailboxes (based on the script’s input parameters), we need the event itself, and once we find that, we can “enrich” the list of mailboxes with the event’s attendee list. The last helper function we have tries to locate an event based on either its identifier (EWSId or GlobalObjectId), or a combination of subject and start/end date. Said function can also be used outside of the script (dot-sourced), in scenarios where you do not have the event identifier, or simply want to leverage the script itself to find a matching event. Whenever you use the Find-Event function for that purpose, you can pass the event object directly to the script.

Here is a good place to discuss the method used to locate the event. We cannot use Graph’s /search/query endpoint, as it is limited to the current user only. The List calendarView method is the preferred one to use when working with events, but as we need to leverage the /events endpoint for the subsequent deletion operation, we might as well opt for a solution solely based on it. Hence, the Find-Event function leverages the List events method. The Delete event operation will take get rid of all instances anyway, so no point looking them up one at a time.

If the event’s Id is provided, a direct call against the /events/{id} endpoint is made. Otherwise, the $filter query operator is leveraged, as the endpoint does not support the $search operator. As multiple events can potentially be returned depending on the filter used, the function allows you to preview their properties and select the desired event out of a GridView. This in turn has some implications for running the script non-interactively and on non-Windows platforms. The former should not be an issue though, as the only way to call the function with such filter is interactively.

As another side note, I want to remind you that if the organizer’s mailbox is still around, there are better methods to use. The Delete events method we leverage here should actually send a cancellation message to any listed meeting attendees when the corresponding meeting is deleted from the organizer’s mailbox, but generally speaking meeting cancellation is not supported in application permissions scenarios.

Moving on, as this article is turning into another novel. Once we have the set of mailboxes to process and the event we want to get rid of, all that is left to do is iterate over each mailbox, check whether an event with matching UID can be found, and if so, delete it. The script is coded with support for ShouldProcess in mind, although by default it will not ask for confirmation, as the assumption is that you want to run the script non-interactively. Use the -Confirm switch, if needed.

How to run the script

2000 words in, it’s finally time to introduce the script! Go fetch your copy from my GitHub repo first.

Next, let’s talk permissions. On Graph’s side of things, we need the Calendars.ReadWrite scope only, as it covers all the operations we are interested in. On Exchange Online’s side of things, the Exchange.ManageAsApp permission is needed in order to run queries in the context of a service principal. In addition, any read-only role that includes the Get-Recipient and Get-DistributionGroupMember cmdlets should do, such as the default View-Only Recipients role, or Global Reader on Entra side.

Once you have an app with sufficient permissions, populate the authentication variables (lines 273-275). You are now ready to run the script! Well, assuming you have the UID/GlobalObjectId value of the event you want removed. If you do not, refer to the earlier section for the method to retrieve it, or dot-source the script and use the Find-Event function to find matching events. Here’s an example:

. .\Graph_Remove_meeting.ps1
Find-Event -Mailbox LidiaH@M365x98983046.OnMicrosoft.com -Subject "Weekly call with Subsidiary Leads"

GraphDeleteEvents1

Using the Find-Event cmdlet allows you to pass the full meeting object, thus negating the need to look the event up in order to fetch the attendees blob. Otherwise, you need to either provide a mailbox (or list of mailboxes) via the -IncludeMailboxes parameter or use the -ProcessAllMailboxes switch instead. Here is the full set of parameters supported by the script:

  • MeetingObject – pass an object obtained previously via the Find-Event cmdlet. The UID property of said object will be used as input for the event id, and the attendees property as input for the list of mailboxes to process. Optional.
  • MeetingId – the UID/GlobalObjectId value of the event you want removed. Required, unless a meeting object is passed.
  • IncludeMailboxes – a list of recipient objects to process. Any identifier accepted by Exchange Online is supported, i.e. you can enter LidiaH instead of LidiaH@M365x98983046.OnMicrosoft.com. Multiple values are accepted. Despite the name of the parameter, you can pass group identifiers, in which case the group (static) membership will be expanded. Required, unless a meeting object is passed.
  • ExcludeMailboxes – a list of recipient objects NOT to process. The parameter is optional and any value you provide is NOT processed further, so make sure to provide a valid email address. You can also provide group addresses, but they will not be resolved to individual recipients. The parameter is optional.
  • ProcessAllMailboxes – an optional switch parameter, use it if you want to process all mailboxes in the tenant. No exclusions!
  • Quiet – use this optional switch parameter to suppress any output.
  • Verbose – use this switch parameter to show additional information as the script progresses.
  • WhatIf – use this switch parameter to “preview” the script’s results, without making any deletions.
  • Confirm – use this switch parameter to require manual confirmation of each deletion.

Below are some examples on how to run the script. If leveraging the Find-Event cmdlet, we can pass an event object directly. Otherwise, provide a valid UID value and a (list of) mailbox(es) to check against.

#Pass an event object. The event will be deleted from any attendee mailboxes found
$event = Find-Event -Mailbox LidiaH@M365x98983046.OnMicrosoft.com -Subject "Weekly call with Subsidiary Leads"
.\Graph_Remove_meeting.ps1 -MeetingObject $event

#Pass an event UID and a list of mailboxes to process. You can use any valid Exchange identifier.
.\Graph_Remove_meeting.ps1 -IncludeMailboxes MeganB,LidiaH@M365x98983046.OnMicrosoft.com  -MeetingId 040000008200E00074C5B7101A82E008000000006BF6047B06FADA01000000000000000010000000033AC367A52B25498C78562320898619

#You can also pass group objects, or exclude mailboxes
.\Graph_Remove_meeting.ps1 -IncludeMailboxes Mark8ProjectTeam,MeganB -ExcludeMailboxes admin@M365x98983046.OnMicrosoft.com -MeetingId 040000008200E00074C5B7101A82E008000000006BF6047B06FADA01000000000000000010000000033AC367A52B25498C78562320898619

#Use the set of common parameters to preview/confirm deletion and provide additional information
.\Graph_Remove_meeting.ps1 -MeetingObject $event -IncludeMailboxes AdeleV -Verbose -WhatIf

GraphDeleteEvents2

Here are also some notes on the Find-Event function. It’s a pure Graph API call, so the only valid identifiers you can provide for the user are GUID or SMTP address. Because we are using the /events endpoint, which unlike the /calendarView one does not expand meeting occurrences, if using a time-based filter, you need to use the creation date. Or go broad instead – the function can return multiple events, in which case you will be prompted to select the desired one via the GridView dialog:

GraphDeleteEvents3

You can also use the Find-Event function with an EWSId value you copied from OWA:

Find-Event -Mailbox MeganB@M365x98983046.OnMicrosoft.com -MeetingId "AAMkAGRmNTUzMzFkLWM5ZGQtNGYyMS04ZmZhLWVhYmUxYjI3MTI2NQBGAAAAAAAxBqmfeULwTqEWxiEhw2AvBwBYBFPTAABCTLcLQcAPBjWPAAAAAAENAABYBFPTAABCTLcLQcAPBjWPAAAAASXeAAA%3D"

Some additional details and closing thoughts

If any meetings were removed, the script will generate a simple CSV file with details as to which mailboxes were processed as well as whether the removal operation succeeded. As the script only supports removing a single meeting at a time, instead of adding a column with the meeting UID, we are stamping it as part of the CSV filename. Example CSV file is show below:

Sample output of the script
Sample output of the script

While supporting the removal of multiple meetings is fairly easy to accommodate in the code, the use of –IncludeMailboxes and/or –ExcludeMailboxes switches can potentially lead to some overhead, with mailboxes being unnecessarily processed. In order to avoid such inefficiencies, the MeetingId/MeetingObject parameters accept single values only. If you want to process multiple meetings at a time, you can easily do so by a simple loop calling the script with the desired parameters. Or update the code, if you prefer.

Lastly, a note of caution. As by default application permissions in the Graph API are directory-wide, any script that leverages such has the potential to do harmful things, especially ones accessing mailbox or file data. Luckily, in the case of Exchange Online, we do have the means to restrict access, as detailed in this article. So do yourself a favor, and before playing with the script, configure some restrictions on the service principal you plan to leverage it with.

 

In summary, in this article we explored the methods available within the Graph API to remove a meeting in scenarios where the organizer’s mailbox is no longer available. A sample script was provided that can be used to find a matching meeting, get its attendees and process their mailboxes to remove any instances of the meeting in question. Alternatively, you can use the brute-force approach and process all mailboxes instead.

As there is still no support for Exchange Online operations within the Graph API, the script also leverages the (unsupported) REST API behind the ExO PowerShell cmdlets, in order to resolve the attendee list. This in turn requires assigning Exchange Online permissions and related directory role to the service principal, but the alternative is to blindly process all user objects and make individual calls to determine whether a mailbox can be found.

Lastly, this solution should be used as a last resort, only in scenarios where the organizer’s mailbox is unrecoverable. For all other scenarios, the Remove-CalendarEvents cmdlet is an easier/better solution. Or even workarounds such as copying the meeting to a new mailbox and stamping the organizer’s legacyExchangeDN value as a X500 entry on the new mailbox object (thanks to fellow MVP Ingo Gegenwarth for that insight). As for Microsoft 365 Groups/Teams meetings, including channel ones, any owner of the Group can remove such (albeit you might have to “unhide” the Group first).

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.