Featured image of post Accessing Content of Microsoft 365 Group Mailboxes Through Microsoft Graph

Accessing Content of Microsoft 365 Group Mailboxes Through Microsoft Graph

In this blog post, I'm explaining how one can access emails or calendar events in Microsoft 365 Group Mailboxes. Even if it doesn't seem possible at first sight...

If you ever need to use the Graph API to access any kind of email related content, the first place you’d usually look is the Graph Mail API. However, if the emails or calendar events you need to access are stored in a Microsoft 365 Group’s mailbox, you’ll notice quite quickly that accessing their content is not supported by this API. But that doesn’t mean it’s impossible.

I recently came across a claim that the Graph API doesn’t support group mailboxes. But I knew that there must be some way to access group emails since I already did that through Power Autmate for this blog post. In fact, the first version of this flow used a shared mailbox that was subscribed to receive all emails sent to the group in its own mailbox and the flow accessed the Teams voicemails from there. But later I figured out how to access them directly. Although we can’t always see what requests Power Automate performs, a lot of them use Graph under the hood which led me to believe that there must be a way to do it from Graph as well.

Group Conversations API

Emails in group conversations are structured differently than in user or shared mailboxes. They are accessed by their conversation, thread and post IDs. A post is basically the equivalent to a message in the normal Graph Mail API.

Group Conversation Example

First, I define my group ID in $groupId.

1
$groupId = "0ad3ddfb-ae4e-4a4e-b564-36231b966ba6"

I’ll use the same group ID throughout this blog. With the code below, I can list group conversations in a group mailbox. Note that the result is usually paginated and that you’ll need to fetch all pages. But in this blog post I’m keeping it easy and only ever select one example email.

This will fetch the list of conversations in the specified group and select the first one as an example.

1
2
3
$conversationsUri = "https://graph.microsoft.com/v1.0/groups/$groupId/conversations"
$conversations = (Invoke-MgGraphRequest -Method GET -Uri $conversationsUri -OutputType PSObject).value
$conversation = $conversations[0]

Output of $conversation

Since I know know the group ID, I can fetch its threads.

1
2
3
4
$conversationId = [uri]::EscapeDataString($conversation.id)
$threadsUri = "https://graph.microsoft.com/v1.0/groups/$groupId/conversations/$conversationId/threads"
$threads = (Invoke-MgGraphRequest -Method GET -Uri $threadsUri -OutputType PSObject).value
$thread = $threads[0]

Output of $thread

And with both the conversation and the thread ID I can finally fetch posts (emails).

1
2
3
4
$threadId = [uri]::EscapeDataString($thread.id)
$postsUri = "https://graph.microsoft.com/v1.0/groups/$groupId/conversations/$conversationId/threads/$threadId/posts"
$posts = (Invoke-MgGraphRequest -Method GET -Uri $postsUri -OutputType PSObject).value
$post = $posts[0]

Output of $post

As you can see, each output contains slightly different data. So, to get a complete view of that email, I need to stitch it together.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
$groupEmail = [pscustomobject]@{
    ConversationId    = $conversation.id
    ConversationTopic = $conversation.topic
    ThreadId          = $thread.id
    ThreadTopic       = $thread.topic
    PostId            = $post.id
    ReceivedDateTime  = $post.receivedDateTime
    From              = $post.from.emailAddress
    Sender            = $post.sender.emailAddress
    HasAttachments    = $post.hasAttachments
    Preview           = $conversation.preview
    Body              = $post.body
}

Output of $groupEmail

Caveats of Group Conversations API

The conversation endpoint only enumerates items from the Inbox. Once emails are moved out of there, they’re not returned anymore. It also only indicates if the message has attachments or not. Actually accessing them is only possible with delegated authentication which I did not test in this case as I was performing all tests with application permissions only. I’ll talk about permissions a bit more further down the line.

What Else is Different With Groups?

Group mailboxes are quite different to normal mailboxes. For example, support for rules and custom folders must be turned on at the organization level. This process is described here on Microsoft Learn.

With a tenant’s default settings (custom folders and rules disabled), a group mailbox only exposes the Inbox and Deleted Items folder in Outlook even though the mailbox technically still contains standard folders such as Calendar, Contacts, Drafts, Archive, Tasks and so on. But getting their contents through the Graph API is not very straight forward. That’s what I’m writing about today.

Exchange Online PowerShell can get a group mailbox’s folder statistics but not its actual mailbox folders.

1
$folderStatistics = Get-MailboxFolderStatistics -Identity $groupId

Output of $folderStatistics

As you can see, there is way more than what Outlook shows us. But you could argue that this applies to normal folders as well.

Group Mailbox folders in Outlook on the Web

I enabled custom folders and created a folder called Test in my test group mailbox and I’ll walk you through how you can access emails through Graph in folders other than Inbox as well.

Pay attention to the ContentMailboxGuid above. In this case I got it through Exchange PowerShell but I’ll show you how you can get it from Graph as well. We’ll need this to fetch messages from Graph later.

Endpoints and Permissions Map

Below is a map that shows which endpoints I used to get what kind of content, which IDs need to be known before the API request and what permissions it needs.

EndpointWhat I getID I needApplication permissionDelegated permission
GET /groups/{groupId}/conversationsConversations exposed by the normal group conversation collectionGroup IDGroup-Conversation.Read.AllGroup-Conversation.Read.All
GET /groups/{groupId}/conversations/{conversationId}/threadsThreads in an enumerated conversationGroup and conversation IDGroup-Conversation.Read.AllGroup-Conversation.Read.All
GET /groups/{groupId}/conversations/{conversationId}/threads/{threadId}/postsPosts in an enumerated threadGroup, conversation, and thread IDGroup-Conversation.Read.AllGroup-Conversation.Read.All
GET /admin/exchange/mailboxes/{mailboxId}/foldersMailbox folders, including custom folders, Deleted Items, and CalendarExchange mailbox IDMailboxFolder.Read.AllMailboxFolder.Read
GET /admin/exchange/mailboxes/{mailboxId}/folders/{folderId}/itemsItems and selected MAPI properties from any discovered folderExchange mailbox and folder IDMailboxItem.Read.AllMailboxItem.Read
GET /groups/{groupId}/conversations/{conversationId}/threads/{threadId}/posts/{postId}One post directly, including a post no longer returned by conversation enumerationGroup, conversation, thread, and post IDGroup-Conversation.Read.AllGroup-Conversation.Read.All
GET /groups/{groupId}/eventsGroup event collectionGroup IDNot supportedGroup.Read.All
GET /groups/{groupId}/events/{eventId}One known group eventGroup and event IDCalendars.ReadCalendars.Read

Finding the Mailbox GUID from Group Conversations API Results

I showed you before we can get the Mailbox GUID from Exchange’s Get-MailboxFolderStatistics function. Now we’ll use some PowerShell trickery to get it directly from the Graph API.

A conversation ID returned by the group conversations endpoint looks something like this:

1
AAQkADkyMmU5NzY3LTdmZWYtNDU1MS04YWQxLTU0ZjBkNTQ0MzVkMwAQAHZjhRl8ul1NkSEqRetlZNA=

I would have expected this to be a normal Base64 encoded string but when I tried to decode it I got nothing. Then I asked GitHub Copilot and I’m going to level with you here and disclose that I did not write this code myself.

1
2
3
4
5
6
7
8
9
$base64ConversationId = $conversation.id.Replace('-', '+').Replace('_', '/')
$base64ConversationId += '=' * ((4 - ($base64ConversationId.Length % 4)) % 4)

$conversationIdBytes = [Convert]::FromBase64String($base64ConversationId)
$conversationIdText = [Text.Encoding]::ASCII.GetString($conversationIdBytes)
$mailboxGuid = [regex]::Match(
    $conversationIdText,
    '(?i)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'
).Value

As far as I know, this isn’t documented anywhere. But the code above converts a conversation ID to the same Mailbox GUID I found in Exchange Online PowerShell:

1
2
PS C:\Temp> $mailboxGuid
922e9767-7fef-4551-8ad1-54f0d54435d3

The Graph Mailbox Import and Export API

This API is primarily used to import and export mailbox items but I’ve discovered that it can also be used to fetch emails of group mailboxes.

To do that, I need to combine the Mailbox GUID with the tenant ID:

1
2
3
4
$tenantId = (Get-MgContext).TenantId
$mailboxId = "MBX:${mailboxGuid}@${tenantId}"
$encodedMailboxId = [uri]::EscapeDataString($mailboxId)
$mailboxUri = "https://graph.microsoft.com/v1.0/admin/exchange/mailboxes/$encodedMailboxId"

Since I wouldn’t get my custom Test folder in the first result page, I’m using the @odata.nextLink property to paginate through all pages here.

1
2
3
4
5
6
7
8
$foldersUri = "${mailboxUri}/folders"
$folders = @(
    do {
        $response = Invoke-MgGraphRequest -Method GET -Uri $foldersUri -OutputType PSObject
        $response.value
        $foldersUri = $response.'@odata.nextLink'
    } until (-not $foldersUri)
)

Output of $folders

Bingo! I can see all well known folders and my custom Test folder and their folder IDs.

Reading Items from a Group Mailbox Folder

Now let’s inspect that Test folder by first storing its folder ID in $folderId.

1
2
3
$folderName = "Test"
$folder = $folders | Where-Object displayName -eq $folderName | Select-Object -First 1
$folderId = [uri]::EscapeDataString($folder.id)

This again requires some special sauce for which I used Copilots help.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
$propertyFilter = "id eq 'String 0x0037' or id eq 'String 0x1035' or id eq 'String 0x1000' or id eq 'Binary 0x1013' or id eq 'Boolean 0x0E1B' or id eq 'Binary 0x3013'"
$encodedPropertyFilter = [uri]::EscapeDataString($propertyFilter)
$itemsUri = "${mailboxUri}/folders/$folderId/items?`$expand=singleValueExtendedProperties(`$filter=$encodedPropertyFilter)"
$items = @(
    do {
        $response = Invoke-MgGraphRequest -Method GET -Uri $itemsUri -OutputType PSObject
        $response.value
        $itemsUri = $response.'@odata.nextLink'
    } while ($itemsUri)
)
$item = $items[0]

The MAPI properties translate like this:

PropertyMeaning
String 0x0037Subject
String 0x1035Internet message ID
String 0x1000Plain-text body, observed truncated at 255 characters
Binary 0x1013HTML body encoded as Base64
Boolean 0x0E1BWhether the item has attachments
Binary 0x3013Conversation ID hash used to reconstruct group IDs

Output of $item

As you can see the $item includes the most important information such as the subject and body.

This is the same email in Outlook on the Web

Retrieving a Non-Enumerated Post Directly

I already mentioned that the group conversations API only returns emails that sit in a group’s Inbox. But what if we know the group email’s post ID?

We can see the post ID in the $item properties above already. But we still need the conversation and thread ID. Let’s call our friend Copilot to help us out again.

 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
$conversationIdProperty = $item.singleValueExtendedProperties |
    Where-Object id -eq 'Binary 0x3013' |
    Select-Object -First 1

$conversationHash = [Convert]::FromBase64String(
    $conversationIdProperty.value
)
$mailboxGuidBytes = [Text.Encoding]::ASCII.GetBytes($mailboxGuid)

$conversationIdBytes =
    [byte[]](0, 4, 36, 0) +
    $mailboxGuidBytes +
    [byte[]](0, 16, 0) +
    $conversationHash

$conversationId = [Convert]::ToBase64String($conversationIdBytes).
    Replace('+', '-').Replace('/', '_')

$threadIdBytes =
    [byte[]](0, 4, 36, 0) +
    $mailboxGuidBytes +
    [byte[]](3, 36, 0, 16, 0) +
    $conversationHash +
    [byte[]](16, 0) +
    $conversationHash

$threadId = [Convert]::ToBase64String($threadIdBytes).
    Replace('+', '-').Replace('/', '_')

$postId = $item.id

Now I have all 3 IDs. Let’s put them to the test and call the group conversations endpoint with them directly.

1
2
3
4
5
6
$encodedConversationId = [uri]::EscapeDataString($conversationId)
$encodedThreadId = [uri]::EscapeDataString($threadId)
$encodedPostId = [uri]::EscapeDataString($postId)

$postUri = "https://graph.microsoft.com/v1.0/groups/$groupId/conversations/$encodedConversationId/threads/$encodedThreadId/posts/$encodedPostId"
$post = Invoke-MgGraphRequest -Method GET -Uri $postUri -OutputType PSObject

Output of $post

It works! And it also works with the shorter route by only using /groups/threads/$encodedThreadId/posts/$encodedPostId. So, in other words, the group conversation/thread endpoint only return items from the Inbox in their list operations but if you know at least a thread and post ID, you can also use this endpoint to fetch group emails from other folders such as custom folders or Deleted Items.

Here’s an example of getting an email through Graph from Deleted Items.

Deleted email in Group Mailbox in Outlook on the Web Deleted email in Group Mailbox retrieved through Graph

Getting Group Calendar Events

Like Conversations, the Groups API also officially supports Events. But this endpoint only supports delegated permissions for its enumeration/list events operation.

But if I get an event ID from the /admin/exchange/mailboxes API first, I can query the Group Events endpoint with application permission as well.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
$calendarFolder = $folders |
    Where-Object displayName -eq 'Calendar' |
    Select-Object -First 1

$calendarFolderId = [uri]::EscapeDataString($calendarFolder.id)
$calendarItemsUri = "${mailboxUri}/folders/$calendarFolderId/items"
$calendarItems = @(
    do {
        $response = Invoke-MgGraphRequest -Method GET -Uri $calendarItemsUri -OutputType PSObject
        $response.value
        $calendarItemsUri = $response.'@odata.nextLink'
    } while ($calendarItemsUri)
)
$knownEventId = $calendarItems[0].id

$calendarItems returns IPM.Appointment objects and their IDs work to call the Group Events API directly with application permissions.

1
2
3
$eventId = [uri]::EscapeDataString($knownEventId)
$eventUri = "https://graph.microsoft.com/v1.0/groups/$groupId/events/$eventId"
$event = Invoke-MgGraphRequest -Method GET -Uri $eventUri -OutputType PSObject

The Group Calendar endpoint works as well.

1
2
$eventUri = "https://graph.microsoft.com/v1.0/groups/$groupId/calendar/events/$eventId"
$event = Invoke-MgGraphRequest -Method GET -Uri $eventUri -OutputType PSObject

Output of $event

Summary

Nobody said that getting group emails through Graph is easy but it’s definitely possible in a limited but reasonable capacity. For example, I wasn’t able to get the MIME/original email content in this way. Getting attachments also comes with some significant limitations as they only work with delegated authentication. In my opinion, what I’ve demonstrated here is better than nothing.

This is a perfect example of how immensely helpful AI coding agents like GitHub Copilot can be in a situation like this. Microsoft Learn is so vast that I’d argue that no human will never read every single article or even know about each article’s existence. Most services and APIs are documented very well but sometimes it’s still a bit siloed and it can be quite challenging to connect the dots. But this is exactly where AI excels. It can discover, read and understand so many things at once and then use trial and error to discover things that aren’t immediately clear from individual documentation pages or would just take way more time to figure out manually.

Sometimes, a bit of curiosity, persistence and smart prompts is all it takes to get to the bottom of something.

Licensed under CC BY-NC-SA 4.0
Hosted on GitHub Pages
Built with Hugo
Theme Stack designed by Jimmy