In the past, I’ve written several times about using PowerShell to report the membership of Exchange Online distribution lists. Support of multiple mail-enabled objects, including nested groups, makes the extraction of full distribution list membership trickier than simply running the Get-DistributionGroupMember cmdlet and a variety of techniques have been used over the years to expand and report all members using Exchange Online and Azure AD cmdlets and Microsoft Graph API requests.
Normally, I don’t return to the same topic again and again. The reason why I’m back here for a third bite at the cherry is that Microsoft will deprecate the Azure AD PowerShell module on June 30, 2023. Although it’s possible to use Microsoft Graph API requests to report distribution list membership (with a caveat), some would prefer to convert their scripts to another PowerShell module rather than going full-blown Graph. I guess the Microsoft Graph PowerShell SDK is that half-way stop, so here goes.
Using the Graph SDK with Group Memberships
It’s important to understand that the Microsoft Graph PowerShell SDK interacts with Azure AD groups. Distribution lists are Exchange Online objects that synchronize to appear as groups in Azure AD. However, although distribution lists support membership of mail-enabled objects that are unique to Exchange, like mail-enabled public folders, these objects don’t show up in membership reported by Azure AD. The reason is simple: the objects don’t exist in Azure AD. What does show up are the objects supported by Azure AD: user accounts (including guests), contacts, and groups. That’s what you see when you run the Get-MgGroupMember cmdlet to retrieve group membership.
Because distribution groups support nested groups, we need a way to expand the members of nested groups and resolve duplicate member entries that might exist. This can be done using a Graph query to fetch transitive members. The transitive query does all the work to expand nested groups and return a unified set of members.
Because a Graph API request exists to fetch transitive members, an equivalent cmdlet is available in the Microsoft Graph PowerShell SDK. That cmdlet is Get-MgGroupTransitiveMember. For example, this call fetches all the members in the group pointed to by the variable $DL.ExternalDirectoryObjectId.
Objects synchronized from Exchange Online to Azure AD store their Azure AD identifier (GUID) in the ExternalDirectoryObjectId property. For instance, a mailbox stores the identifier for its owning Azure AD user account in the property. Azure AD treats a distribution list like any other group, and so it has a group identifier that’s stored in the property. That identifier is the one we use to extract distribution list membership with Get-MgGroupTransitiveMember.
Get-MgGroupTransitiveMember returns a list of identifiers. In earlier versions of the Microsoft Graph PowerShell SDK, you had to resolve the identifiers into useful information, like the display names of individual group members. Now, the group cmdlets return the information in an array of member details stored in the AdditionalProperties property, which means that we can find what we want by extracting it from the array. For convenience, I usually extract the array into a separate variable:
You might ask why Microsoft decided to update the groupcmdlets to output the member data in a separate property instead of changing the default to output the list of members (which is how cmdlets like Get-AzureADGroupMember work). One explanation is that changing the output of a cmdlet will break existing scripts. In that context, it’s understandable to include a new property.
Parsing Distribution List Membership
After fetching the transitive membership for a distribution list, the remaining task is to figure out how many members of the different categories are in the set (members, contacts, and groups). This is easily done by counting the items in the set. After it gathers this basic information about the group, the script updates a PowerShell list with the data.
You can drive some other processing from the list. For instance, you might decide to convert any distribution list with over 100 members to a team (use the same kind of approach as described here to covert a dynamic distribution list to a team). An easier decision might be to remove any distribution list found with zero members on the basis that they’re no longer in use. This is easily done with:
To be safe, I left the confirmation prompt in place so that you’re asked to confirm the deletion of each distribution list. You can suppress the prompt by adding -Confirm:$False to the command.
Reporting Distribution List Membership
The final stage is to generate output, which the script does in the form of a CSV file and HTML file (Figure 1). This ground is well-known and there’s no mystery in the code needed to generate the files.
Figure 1: Output of the distribution list membership report
Converting from Azure AD cmdlets to Microsoft Graph PowerShell SDK cmdlets is not challenging – once you understand how the Graph SDK works. The trick is to make no assumptions about the input parameters or the output a cmdlet produces. You might expect things to work in a certain way, but the chances are that they won’t, so go into the conversion in the spirit of a voyage of discovery and you won’t be disappointed. To help, here’s the script to report distribution list members using the Microsoft Graph PowerShell SDK.
Learn about exploiting Exchange Online and the rest of Office 365 by subscribing to the Office 365 for IT Pros eBook. Use our experience to understand what’s important and how best to protect your tenant.
4 Replies to “Reporting Distribution List Membership with the Microsoft Graph PowerShell SDK”
Hello
I love this report .. but i wanted to know how to just filter out a certain domain name. I run a multi tenant exchange environment and it takes forever to run through 14k DLs. i imagine it being a -Filter {-like@domainname} type of command but it just errors out on me.
{"id":null,"mode":"button","open_style":"in_modal","currency_code":"EUR","currency_symbol":"\u20ac","currency_type":"decimal","blank_flag_url":"https:\/\/office365itpros.com\/wp-content\/plugins\/tip-jar-wp\/\/assets\/images\/flags\/blank.gif","flag_sprite_url":"https:\/\/office365itpros.com\/wp-content\/plugins\/tip-jar-wp\/\/assets\/images\/flags\/flags.png","default_amount":100,"top_media_type":"featured_image","featured_image_url":"https:\/\/office365itpros.com\/wp-content\/uploads\/2022\/11\/cover-141x200.jpg","featured_embed":"","header_media":null,"file_download_attachment_data":null,"recurring_options_enabled":true,"recurring_options":{"never":{"selected":true,"after_output":"One time only"},"weekly":{"selected":false,"after_output":"Every week"},"monthly":{"selected":false,"after_output":"Every month"},"yearly":{"selected":false,"after_output":"Every year"}},"strings":{"current_user_email":"","current_user_name":"","link_text":"Virtual Tip Jar","complete_payment_button_error_text":"Check info and try again","payment_verb":"Pay","payment_request_label":"Office 365 for IT Pros","form_has_an_error":"Please check and fix the errors above","general_server_error":"Something isn't working right at the moment. Please try again.","form_title":"Office 365 for IT Pros","form_subtitle":null,"currency_search_text":"Country or Currency here","other_payment_option":"Other payment option","manage_payments_button_text":"Manage your payments","thank_you_message":"Thank you for supporting the work of Office 365 for IT Pros!","payment_confirmation_title":"Office 365 for IT Pros","receipt_title":"Your Receipt","print_receipt":"Print Receipt","email_receipt":"Email Receipt","email_receipt_sending":"Sending receipt...","email_receipt_success":"Email receipt successfully sent","email_receipt_failed":"Email receipt failed to send. Please try again.","receipt_payee":"Paid to","receipt_statement_descriptor":"This will show up on your statement as","receipt_date":"Date","receipt_transaction_id":"Transaction ID","receipt_transaction_amount":"Amount","refund_payer":"Refund from","login":"Log in to manage your payments","manage_payments":"Manage Payments","transactions_title":"Your Transactions","transaction_title":"Transaction Receipt","transaction_period":"Plan Period","arrangements_title":"Your Plans","arrangement_title":"Manage Plan","arrangement_details":"Plan Details","arrangement_id_title":"Plan ID","arrangement_payment_method_title":"Payment Method","arrangement_amount_title":"Plan Amount","arrangement_renewal_title":"Next renewal date","arrangement_action_cancel":"Cancel Plan","arrangement_action_cant_cancel":"Cancelling is currently not available.","arrangement_action_cancel_double":"Are you sure you'd like to cancel?","arrangement_cancelling":"Cancelling Plan...","arrangement_cancelled":"Plan Cancelled","arrangement_failed_to_cancel":"Failed to cancel plan","back_to_plans":"\u2190 Back to Plans","update_payment_method_verb":"Update","sca_auth_description":"Your have a pending renewal payment which requires authorization.","sca_auth_verb":"Authorize renewal payment","sca_authing_verb":"Authorizing payment","sca_authed_verb":"Payment successfully authorized!","sca_auth_failed":"Unable to authorize! Please try again.","login_button_text":"Log in","login_form_has_an_error":"Please check and fix the errors above","uppercase_search":"Search","lowercase_search":"search","uppercase_page":"Page","lowercase_page":"page","uppercase_items":"Items","lowercase_items":"items","uppercase_per":"Per","lowercase_per":"per","uppercase_of":"Of","lowercase_of":"of","back":"Back to plans","zip_code_placeholder":"Zip\/Postal Code","download_file_button_text":"Download File","input_field_instructions":{"tip_amount":{"placeholder_text":"How much would you like to tip?","initial":{"instruction_type":"normal","instruction_message":"How much would you like to tip? Choose any currency."},"empty":{"instruction_type":"error","instruction_message":"How much would you like to tip? Choose any currency."},"invalid_curency":{"instruction_type":"error","instruction_message":"Please choose a valid currency."}},"recurring":{"placeholder_text":"Recurring","initial":{"instruction_type":"normal","instruction_message":"How often would you like to give this?"},"success":{"instruction_type":"success","instruction_message":"How often would you like to give this?"},"empty":{"instruction_type":"error","instruction_message":"How often would you like to give this?"}},"name":{"placeholder_text":"Name on Credit Card","initial":{"instruction_type":"normal","instruction_message":"Enter the name on your card."},"success":{"instruction_type":"success","instruction_message":"Enter the name on your card."},"empty":{"instruction_type":"error","instruction_message":"Please enter the name on your card."}},"privacy_policy":{"terms_title":"Terms and conditions","terms_body":null,"terms_show_text":"View Terms","terms_hide_text":"Hide Terms","initial":{"instruction_type":"normal","instruction_message":"I agree to the terms."},"unchecked":{"instruction_type":"error","instruction_message":"Please agree to the terms."},"checked":{"instruction_type":"success","instruction_message":"I agree to the terms."}},"email":{"placeholder_text":"Your email address","initial":{"instruction_type":"normal","instruction_message":"Enter your email address"},"success":{"instruction_type":"success","instruction_message":"Enter your email address"},"blank":{"instruction_type":"error","instruction_message":"Enter your email address"},"not_an_email_address":{"instruction_type":"error","instruction_message":"Make sure you have entered a valid email address"}},"note_with_tip":{"placeholder_text":"Your note here...","initial":{"instruction_type":"normal","instruction_message":"Attach a note to your tip (optional)"},"empty":{"instruction_type":"normal","instruction_message":"Attach a note to your tip (optional)"},"not_empty_initial":{"instruction_type":"normal","instruction_message":"Attach a note to your tip (optional)"},"saving":{"instruction_type":"normal","instruction_message":"Saving note..."},"success":{"instruction_type":"success","instruction_message":"Note successfully saved!"},"error":{"instruction_type":"error","instruction_message":"Unable to save note note at this time. Please try again."}},"email_for_login_code":{"placeholder_text":"Your email address","initial":{"instruction_type":"normal","instruction_message":"Enter your email to log in."},"success":{"instruction_type":"success","instruction_message":"Enter your email to log in."},"blank":{"instruction_type":"error","instruction_message":"Enter your email to log in."},"empty":{"instruction_type":"error","instruction_message":"Enter your email to log in."}},"login_code":{"initial":{"instruction_type":"normal","instruction_message":"Check your email and enter the login code."},"success":{"instruction_type":"success","instruction_message":"Check your email and enter the login code."},"blank":{"instruction_type":"error","instruction_message":"Check your email and enter the login code."},"empty":{"instruction_type":"error","instruction_message":"Check your email and enter the login code."}},"stripe_all_in_one":{"initial":{"instruction_type":"normal","instruction_message":"Enter your credit card details here."},"empty":{"instruction_type":"error","instruction_message":"Enter your credit card details here."},"success":{"instruction_type":"normal","instruction_message":"Enter your credit card details here."},"invalid_number":{"instruction_type":"error","instruction_message":"The card number is not a valid credit card number."},"invalid_expiry_month":{"instruction_type":"error","instruction_message":"The card's expiration month is invalid."},"invalid_expiry_year":{"instruction_type":"error","instruction_message":"The card's expiration year is invalid."},"invalid_cvc":{"instruction_type":"error","instruction_message":"The card's security code is invalid."},"incorrect_number":{"instruction_type":"error","instruction_message":"The card number is incorrect."},"incomplete_number":{"instruction_type":"error","instruction_message":"The card number is incomplete."},"incomplete_cvc":{"instruction_type":"error","instruction_message":"The card's security code is incomplete."},"incomplete_expiry":{"instruction_type":"error","instruction_message":"The card's expiration date is incomplete."},"incomplete_zip":{"instruction_type":"error","instruction_message":"The card's zip code is incomplete."},"expired_card":{"instruction_type":"error","instruction_message":"The card has expired."},"incorrect_cvc":{"instruction_type":"error","instruction_message":"The card's security code is incorrect."},"incorrect_zip":{"instruction_type":"error","instruction_message":"The card's zip code failed validation."},"invalid_expiry_year_past":{"instruction_type":"error","instruction_message":"The card's expiration year is in the past"},"card_declined":{"instruction_type":"error","instruction_message":"The card was declined."},"missing":{"instruction_type":"error","instruction_message":"There is no card on a customer that is being charged."},"processing_error":{"instruction_type":"error","instruction_message":"An error occurred while processing the card."},"invalid_request_error":{"instruction_type":"error","instruction_message":"Unable to process this payment, please try again or use alternative method."},"invalid_sofort_country":{"instruction_type":"error","instruction_message":"The billing country is not accepted by SOFORT. Please try another country."}}}},"fetched_oembed_html":false}
Hello
I love this report .. but i wanted to know how to just filter out a certain domain name. I run a multi tenant exchange environment and it takes forever to run through 14k DLs. i imagine it being a -Filter {-like@domainname} type of command but it just errors out on me.
Please help
thank you again for creating this
Change the command that fetches the DLs to something like this:
PS C:\temp> $DLs = Get-DistributionGroup -ResultSize Unlimited -Filter {RecipientTypeDetails -ne “Roomlist”} | Select DisplayName, ExternalDirectoryObjectId, ManagedBy, primarysmtpaddress | Where-Object {$_.PrimarySmtpAddress -like “*office365itpros.com*”}
(Change office365itpros.com for whatever domain name you want to search for)
thank you What if mY DLs are On Prem and are not 365 managed groups or 365 DLs? Would the command be different?
If the on-premises DLs are synchronized to Azure AD, the command will find them.