RSS Amplifier

Glen’s @ eMail Development Substack · Feb 17, 2026

Migrating Personal Distribution lists (Contact Groups) code from EWS to the Microsoft Graph API

0
Sign in to vote or save

Glen Scales · Glen’s @ eMail Development Substack

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:

PidLidDistributionListMembers

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:

PidLidDistributionListStream

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 the PidLidDistributionListMembers property 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.

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.

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"

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").primaryMailboxId

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 $uri

I 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 -AutoSize

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 GET call.

  • The Selection Logic: * If the PidLidDistributionListStream property 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.

This stage acts as the “search” for the group’s property data.

  • The Query: It hits the Graph API /beta/admin/exchange/mailboxes endpoint.

  • The Filters: It uses OData filters to find items where:

    • The IPM.DistList class is present (Property 0x001A).

    • 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.

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:

    1. Read 4 bytes (The size of the EntryID).

    2. Extract the EntryID data based on that size.

    3. Read 4 bytes (The size of the One-Off EntryID).

    4. 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.

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 (or 00 00 for Unicode) to “break” the byte stream into three distinct strings:

    • DisplayName

    • AddressType (e.g., “SMTP”)

    • EmailAddress

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.

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!

Read the original on glenscales.substack.com

Comments

Nothing yet. Say the first thing.

    Sign in to join the conversation.