No Point in Having User Mailboxes That Aren’t in Active Use
I don’t like to post repeats of articles, but sometimes it’s necessary. In this case, a reader had difficulty running some PowerShell code in a Petri.com article I wrote in 2019 to discuss how to find unused Exchange Online mailboxes using diagnostic information. Often the issue is something small (like making sure that you use the correct webhook URI), but I don’t write for Petri any longer and no longer have access to the article, so it seemed like a good idea to revisit the topic here.
Often, the second attempt at writing code takes a different approach to the first. You know what needs to be done and might approach the issue in a different way. New PowerShell cmdlets or Microsoft Graph APIs might be available. Or the original code might simply be not very good, even if it’s written to demonstrate a principal rather than be a complete solution.
Microsoft increased prices for Office 365 and Microsoft 365 licenses on March 1, 2022. The extra $3 or so per month might not seem a lot, but it’s always a good thing to avoid paying license fees for unused mailboxes. Of course, email activity is only one aspect of how someone might use Office 365, and usually people don’t pay separately for Exchange Online because Microsoft bundles it in many SKUs like Office 365 E3. Over the last few years, many Office 365 users have reduced the level of email they send and receive by moving communications to Teams, which means that gathering and analyzing activity data from multiple workloads is a better way to conclude if an account is active or not.
Analyzing Sources of Mailbox Information
The original idea was to use diagnostic information extracted from user mailboxes to understand if mailboxes are in active use. I’m only concerned about user mailboxes because they’re the ones which need licenses. Shared mailboxes don’t need licenses unless they are larger than 50 GB or have an archive.
Three sources of information give some insight into the activity level of mailboxes:
The Export-MailboxDiagnosticLogs extracts mailbox diagnostic information (changed subtly over the years). After converting the information to XML format, it’s easy to extract and use the various pieces of data which might tell you about the use of a mailbox.
The Get-ExoMailboxStatistics cmdlet provides some basic statistics about each mailbox because unused mailboxes are usually small and hold relatively few items. Thus, if we see a mailbox that hasn’t been logged into for a while and has less than a couple of hundred items, it’s an indication that that this is an unused mailbox.
The last logon date for a mailbox is a piece of information that’s long been of suspect quality, largely because so many background processes connect to mailboxes to perform housekeeping tasks like retention processing. Mailbox diagnostics include a last logon time property, so the script reports that. As a backup, the script also calls the Get-MgAuditLogSignIn cmdlet to fetch the last signin audit record from Azure AD (which can only go back 30 days). The last signin event might be associated with Exchange Online, but it could also come from another workload. It’s just another check to help detect unused mailboxes.
As usual for demo scripts, we pipe the output to Out-GridView to view the results sorted by the number of days since the last active date reported by mailbox diagnostics (Figure 1). The most unused Exchange Online mailboxes appear at the top.
The new script also includes the code from this post to post notifications about the top 25 potentially unused mailboxes to a Teams channel through the incoming webhook connector (Figure 2). I explored this topic previously in this post and have now refined and integrated the code in a single script.
Figure 2: Posting to Teams about potentially unused Exchange Online mailboxes
The method used to create the body of the message to post to Teams is different from the approach taken in this article. In the first instance, the code deals with a single item. In this script, the body comprises up to 25 different items. In both cases, you end up with a HTML body of JSON data, which is what Teams expects through the incoming webhook connector.
The full script is available on GitHub. Before you run it, be sure to update the code with the webhook URI for the target teams channel.
Doing Something About the Unused Mailboxes
If some suspiciously unused Exchange Online mailboxes come to light, the next step is to figure out what to do with them. You can:
Leave the mailbox alone and find out why its owner is not using the mailbox. Obviously, you’ll need to contact the mailbox owner using something other than email.
Convert the mailbox to be a shared mailbox to remove the need to license the mailbox. However, if the mailbox owner uses other workloads, they might have a license like Office 365 E3 which includes Exchange Online, so converting the mailbox to be a shared mailbox won’t have much impact.
Put the mailbox on litigation hold and then delete the user account. This makes the mailbox inactive and releases any licenses assigned to the account. Exchange Online keeps the mailbox in this state until the hold lapses. This is a bad approach to take if the account is active in other Microsoft 365 workloads, which is why the data analyzed includes the last sign-in date from Azure AD. This article explains how to use the Microsoft Graph usage reports API to report per-user data from multiple workloads.
Relax and do not worry too much. Maybe this isn’t the best way to chase down licensing costs.
Perhaps chasing and closing down unused Exchange Online mailboxes is a pipe dream that isn’t worth the effort. Still, it’s nice to know how to approach the problem.
Learn about working with Exchange Online mailboxes and the rest of Office 365 by subscribing to the Office 365 for IT Pros eBook. Use our experience to understand what’s importance and how best to protect your tenant.
# Get account enabled status
$AccountEnabled = (Get-MgUser -UserId $M.ExternalDirectoryObjectId).AccountEnabled
And then report/sort/filter on that property… It is True if the account is enabled, False if it’s disabled. I have updated the script in GitHub to show how.
You must have a very new mailbox… In any case, the code is PowerShell, so it’s easy to add a check that the $LastActive variable contains some data before attempting to use it.
{"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}
Great stuff, thank you Tony! 🙂
Looks like it will display disabled accounts. Any way to filter those out?
Add a check for the account status:
# Get account enabled status
$AccountEnabled = (Get-MgUser -UserId $M.ExternalDirectoryObjectId).AccountEnabled
And then report/sort/filter on that property… It is True if the account is enabled, False if it’s disabled. I have updated the script in GitHub to show how.
Excellent. Thank you!
Great script but when I get above the 70th user I start getting errors:
Get-MgAuditLogSignIn :
400 Request Header Or Cookie Too Large
400 Bad Request
Request Header Or Cookie Too Large
nginx
Any ideas on how to fix a overly sized cookie?
What version of the SDK are you using? Microsoft just released 1.9.5. Maybe it will help.
Version was older 1.9.2 and the upgrade resolved the issue. Appreciate the help and the quick response.
getting erro start parameter for few mailbox
Line |
30 | $DaysSinceActive = (New-TimeSpan -Start $LastActive -End $Now).Day …
| ~~~~~~~~~~~
| Cannot bind parameter ‘Start’ to the target. Exception setting “Start”: “Cannot convert null to type
| “System.DateTime”.”
You must have a very new mailbox… In any case, the code is PowerShell, so it’s easy to add a check that the $LastActive variable contains some data before attempting to use it.
If ($LastActive) {
$DaysSinceActive = (New-TimeSpan -Start $LastActive -End $Now).Days }
Else {
$DaysSinceActiove = “N/A” }