Migrating Personal Distribution lists (Contact Groups) code from EWS to the Microsoft Graph API
Personal Distribution Lists (also known as Contact Groups) have been a long-lived feature in both Outlook and Exchange. Unfortunately, they remain one of the notable API gaps between EWS and Microsoft Graph that has yet to be fully addressed. Native support for Contact Groups arrived relatively late in EWS (around Exchange 2010), so solving this problem today largely means reverting to the approach used back in Exchange 2007 to do the same thing which is working directly with the extended properties that store group membership. Dealing with Contact groups can be challenging logically because how they store the data in the raw properties is determined by the size of the list your dealing with.
For smaller Contact Groups, membership is stored using the following two multi-valued string properties:
This property contains a list of EntryIDs—either Wrapped EntryIDs or OneOffEntryIDs—one per group member.
Wrapped EntryIDs are used when referencing GAL entries, other Distribution Lists, or existing Exchange contacts.
OneOffEntryIDs are used for standard contacts or ad-hoc (one-off) addresses.
PidLidDistributionListOneOffMembers
This property stores only OneOffEntryIDs. Because it excludes Wrapped EntryIDs, it can be simpler to process if your goal is merely to extract email addresses from the list. However, if the Contact Group contains nested groups, additional work is required to fully expand the membership.
In both cases, each property contains exactly one entry per group member.
Once the combined size of the membership data exceeds 15,000 bytes, these properties are no longer used. At that point, membership data must be stored in the following stream property instead:
Section 3.1.4.2.3.1 (“Adding a Member”) explicitly states that when adding a member causes the total size to exceed 15,000 bytes, the membership data must be migrated to
PidLidDistributionListStream, and thePidLidDistributionListMembersproperty is effectively superseded.
This stream property contains a serialized binary stream of DistListMemberInfo structures (as defined in the MS-OXOCNTC specification). Each DistListMemberInfo structure ultimately wraps either a Wrapped EntryID or a OneOffEntryID—the same underlying data used by the smaller multi-valued properties—but stored in a more scalable format suitable for large Contact Groups.
Enumerating Contact Groups
If your existing EWS code only accesses Contact Group membership for purposes such as mailing or basic expansion, Microsoft Graph historically offered no equivalent capability. Contact Groups have an item class of IPM.DistList and are generally stored in the Contacts folder but in theory can be stored in any folder, yet the standard Graph contacts endpoint provided no way to enumerate or access these objects.
That changed with the introduction of the Import and Export APIs. These APIs expose a generic, item-level endpoint that allows you to enumerate Contact Groups directly and retrieve the extended properties that store their membership data (as described earlier).
Using this capability , Microsoft Graph now provides sufficient functionality to migrate applications that need to:
Enumerate Contact Groups (for example, displaying them in a client UI)
Expand Contact Group membership for downstream operations such as mailing
Expanding nested Contact Groups is more complex, but it is still achievable by recursively resolving group members using the underlying extended properties.
Examples How to enumerate Contact Groups using the Mailbox Items endpoint
1. Connect with Required Permissions
Before running requests, ensure you are connected to the Microsoft Graph PowerShell SDK with the appropriate scope:
PowerShell
Connect-MgGraph -Scopes "MailboxItem.Read,MailboxSettings.Read"2. Get the MailboxId from the setting endpoint
Use the Invoke-MgGraphRequest cmdlet to get the Mailbox id from the setting endpoint
PowerShell
$MailboxId = (Invoke-MgGraphRequest -Method Get -Uri "https://graph.microsoft.com/beta/users/yourmailbox@domain.com/settings/exchange").primaryMailboxId3. Query the Default Contacts folder in the Mailbox to Enumerate all the Contact Groups
Once you have the mailboxId you can then query the default Contacts folder for all the contact Groups using something like
PowerShell
# Full path to the mailbox Contacts items
$baseUri = "https://graph.microsoft.com/beta/admin/exchange/mailboxes/$MailboxId/folders/Contacts/items"
# Filter to only Contact Groups
$filter = "singleValueExtendedProperties/any(p:p/id eq 'String 0x001A' and p/value eq 'IPM.DistList')"
# Expand all extended properties
$expand ="singleValueExtendedProperties(`$filter=" +
"id eq 'String 0x001A' or id eq 'String 0x0037'" +
")"
# Properly build the URI
$uri = "$baseUri`?`$top=999&`$filter=$filter&`$expand=$expand"
$response = Invoke-MgGraphRequest -Method Get -Uri $uriI included the subject name of the group so you could see that in the result. eg if you process the response like
PowerShell
$groups = $response.value | ForEach-Object {
# Get the extended properties for this item
$props = $_.singleValueExtendedProperties
# Find the subject (0x0037) property — the display name of the Contact Group
$groupName = ($props | Where-Object { $_.id -eq 'String 0x37' }).value
# Return a simple object with ID and Name
[PSCustomObject]@{
Id = $_.id
Name = $groupName
}
}
# Show the results
$groups | Format-Table -AutoSizeEnumerating the Group Membership
Once you have obtained the Contact Group ID, perform a specific GET request on that group (using its Id) to retrieve its membership properties.
Important: Do not attempt to pull these properties during the initial Enum (enumeration) request. Because membership data can be quite large, the server will often truncate the properties to save bandwidth, leading to incomplete or corrupted data.
To accurately enumerate the group members, use the following logic based on the group’s size:
The Multi-Property Fetch: Request all three membership properties simultaneously in your
GETcall.The Selection Logic: * If the
PidLidDistributionListStreamproperty is present, prioritize it; this is typically the reliable source for larger groups.If that property is missing, fallback to the alternative membership properties to complete the enumeration.
To Get the Membership properties in a Get Request use
Rest HTTP
GET https://graph.microsoft.com/beta/admin/exchange/mailboxes/MBX:...e@.../folders/Contacts/items/AA....=?
$expand=singleValueExtendedProperties(
$filter=id eq 'String 0x0037'
or id eq 'Binary {00062004-0000-0000-C000-000000000046} Id 0x8064'
),
multiValueExtendedProperties(
$filter=id eq 'BinaryArray {00062004-0000-0000-C000-000000000046} Id 0x8054'
or id eq 'BinaryArray {00062004-0000-0000-C000-000000000046} Id 0x8055'
)
I’ve developed a PowerShell script that includes custom parsers for OneOffEntryID, WrappedEntryID, and the Distribution List (DL) member structure. This demonstrates how you can programmatically expand any Contact Group using nothing more than its name. Note it doesn’t do nested groups but it will return the group you just need to code the expansion logic yourself. You can get the script from
https://github.com/gscales/Powershell-Scripts/blob/master/Graph101/GraphSDK/ContactGroupMod.ps1
To use it (after Connect-MgGraph -Scopes "MailboxItem.Read,MailboxSettings.Read")Invoke-FindContactGroup -MailboxName user@domain -GroupName “Second List” | Expand-GroupMembership
If your a little lost and want to know what this does when you run Invoke-FindContactGroup ... | Expand-GroupMembership, the script performs a three-stage operation: Discovery, De-serialization, and MAPI Parsing.
1. The Discovery Phase (Invoke-FindContactGroup)
This stage acts as the “search” for the group’s property data.
The Query: It hits the Graph API
/beta/admin/exchange/mailboxesendpoint.The Filters: It uses OData filters to find items where:
The
IPM.DistListclass is present (Property0x001A).The Display Name matches your input (Property
0x0037).
The Payload: It explicitly requests the 3 member properties, depending on the size these properties will contain all the members of the group.
2. The De-serialization Phase (Read-DistListMemberInfoArray)
Once the script has the Base64-encoded MemberStream, it treats it like a file on a disk, “walking” the byte array using a cursor.
Step A (The Header): It reads the first few bytes to determine the version and the total CountOfEntries.
Step B (The Member Loop): For every member found in the count, it performs a sequenced read:
Read 4 bytes (The size of the EntryID).
Extract the EntryID data based on that size.
Read 4 bytes (The size of the One-Off EntryID).
Extract the One-Off data (this is the “wrapped” identity containing the email).
Step C (The Terminator): It confirms the stream ends with 8 null bytes, ensuring the binary data isn’t truncated.
3. The Parsing Phase (Invoke-ParseOneOffEntryID)
Finally, the script takes the One-Off EntryID (the “Wrapped” part) and translates it from binary back into a human-readable object.
Endian Conversion: It handles the GUIDs by flipping the first 8 bytes (Little-Endian to Big-Endian), which is necessary for MAPI-standard GUID representation.
Unicode Detection: It checks a specific bitmask in the header (Byte 23). If the bit is set, it reads the strings as UTF-16; otherwise, it uses ASCII.
Null-Terminated Extraction: It scans for
00(or00 00for Unicode) to “break” the byte stream into three distinct strings:DisplayNameAddressType(e.g., “SMTP”)EmailAddress
The Final Result
The script returns a clean PowerShell object. It effectively bridges the gap between Modern REST APIs (Microsoft Graph) and MAPI binary storage, allowing you to see exactly who is inside an Outlook-created Contact Group.
Conclusion
If you’ve made it this far, you’re likely wishing Microsoft had carried over the clean abstractions from the EWS days, which made expanding and modifying groups much more intuitive and easy to use. However, if you’re facing an “API squeeze” and need to get this done in Graph, it is achievable—even if it feels like we’re back in the Exchange 2007 era.
I haven’t covered adding or removing entries here, as that process is more complex and carries a real risk of corrupting the list if handled incorrectly. Proceed with caution!


I haven't heard if this is going to becoming Graph yet. Like many others, I’ve been seeking clarity on this for a while, as a clear roadmap from Microsoft—detailing exactly what will and won't be ported from EWS—would be invaluable. For enterprise environments and ISV's, these transitions often require months of re-engineering or process adjustments. so a comprehensive migration list would help the community manage these shifts much more effectively. I think maybe a bit of Agile blindness focusing too much on one thing rather then taking a holistic view. It's not just Microsoft that does this and it probably where a lot of the EWS to Graph app transitions are going to have issues.
Thank you for this. Do you have any insight into if/when Microsoft might add support natively in Graph? I'll be kind of pissed if I spend the many hours to implement this (especially creating lists) and the next day I see it added to Graph. It still seems unbelievable to me that this wasn't done a long time ago. I would have thought it should be straightforward to port the underlying code from EWS to Graph.