Mailbox and Folder Permissions in Exchange Online (Microsoft 365)

Hello all,

I’m a newcomer to the forum AND a relatively new PowerShell user. Thanks to Chat GPT, I have managed to develop a script that I have wanted someone to create for at least a decade. Now that I have it, I find it to be extremely slow. It can take hours or even days for this script to run against an Exchange Online tenant.

In summary, the script scans all or a list of mailboxes, and then exports two sets of files:

  1. Mailbox and folder SHARING permissions - one file per mailbox. This report is similar to opening each mailbox and looking at the sharing permissions for each folder. It’s obviously way faster to produce this data programmatically.

  2. Mailbox and folder ACCESS permissions - one file per user. This report is not available unless you generate it, so it’s the more valuable part of the script. It’s also what takes the most time.

It’s super helpful for troubleshooting purposes to see a report that shows me everything that a user has permission to across a set of mailboxes or all mailboxes in a tenant, and I know that it would take even more time for a human to generate all of the output that this script gives me, so it’s worth the wait. I’m just hoping that someone out there might be able to help significantly reduce the time it takes to scan the mailboxes and especially the folders.

Without further ado, here’s the code:

# Connect to Exchange Online PowerShell
Connect-ExchangeOnline

# Define an ArrayList to store mailbox permissions
$mailboxPermData = New-Object System.Collections.ArrayList

# Prompt the user for input
$choice = Read-Host "Do you want to get all mailboxes (A) or provide a list of email addresses (L)? (A/L)"

# Check the user's choice
switch ($choice.ToUpper()) {
    'A' {
        # Get all mailboxes in the Exchange Online tenant
        $mailboxes = Get-Mailbox -ResultSize Unlimited
    }
    'L' {
        # Prompt the user to provide a list of email addresses
        $emailAddresses = Read-Host "Enter the list of email addresses separated by commas"
        $emailList = $emailAddresses -split "," | ForEach-Object { $_.Trim() } # Trim any leading/trailing spaces
        
        # Initialize an array to store mailbox objects
        $mailboxes = @()
        
        # Fetch mailboxes corresponding to each email address
        foreach ($email in $emailList) {
            $mailbox = Get-Mailbox -Identity $email -ErrorAction SilentlyContinue
            if ($mailbox) {
                $mailboxes += $mailbox
            } else {
                Write-Warning "Mailbox with email address '$email' not found."
            }
        }
    }
    Default {
        Write-Host "Invalid choice. Exiting script."
        return
    }
}

# Define mailboxes excluded from permissions check
$excludedMailboxes = @(
    "DiscoverySearchMailbox{D919BA05-46A6-415f-80AD-7E09334BB852}@tenant.onmicrosoft.com",
    "mailbox@domain.com"
)

# Define excluded folders
# "\Foldername" excludes the specified folder but subfolders are not excluded.
# "\Foldername*" excludes the specified folder and subfolders.
# "\Foldername\*" excludes only subfolders.
$excludedFolders = @(
    "\Top of Information Store*",
    "\Archive*",
    "\Audits*",
    "\Calendar\*",
    "\Calendar Logging",
    "\Clutter",
    "\Contacts\*",
    "\Conversation Action Settings*",
    "\Conversation History*",
    "\Deleted Items\*",
    "\Deletions*",
    "\DiscoveryHolds*",
    "\Drafts\*",
    "\EventCheckPoints*",
    "\ExternalContacts*",
    "\Files*",
    "\Journal*",
    "\NFS\NFS Archive\*",
    "\PersonMetadata*",
    "\Purges*",
    "\Quick Step Settings*",
    "\Recoverable Items*",
    "\RSS Feeds*",
    "\Social Activity Notifications*",
    "\SubstrateHolds*",
    "\Sync Issues*",
    "\Tasks\*",
    "\Versions*",
    "\WebExtAddIns*",
    "\Yammer Root*"
)

# Iterate through each mailbox
foreach ($mailbox in $mailboxes) {

    #Get mailbox's email address
    $mailboxEmail = $mailbox.PrimarySmtpAddress
    Write-Host "Processing mailbox: $mailboxEmail"
    
    # Skip excluded mailbox as defined in the $excludedMailboxes array
    if ($excludedMailboxes -contains $mailboxEmail) {
        Write-Host "     Excluded"
        continue
    }

    # Get mailbox permissions for the current mailbox
    $mailboxPerm = Get-MailboxPermission -Identity $mailboxEmail | Where-Object { $_.User -ne "NT AUTHORITY\SELF" }
    
    # Iterate through each permission entry
    foreach ($perm in $mailboxPerm) {

        # Retrieve user email address if the user exists
        $userEmail = if ($perm.User -ne "Default" -and $perm.User -ne "Anonymous") { (Get-Recipient -Identity $perm.User -ErrorAction SilentlyContinue).PrimarySmtpAddress }

        # Export mailbox permissions
        $mailboxPermData.Add([PSCustomObject]@{
            Mailbox          = $mailboxEmail
            Path             = "\"
            ObjectType       = "Mailbox"
            UserDisplayName  = $perm.User
            UserEmailAddress = $userEmail
            UserAccessRights = $perm.AccessRights
        }) | Out-Null
    }

    # Get folder statistics for the mailbox
    $folderStats = Get-MailboxFolderStatistics -Identity $mailboxEmail

    # Iterate through each folder in the mailbox
    foreach ($folder in $folderStats) {

        # Reformat path names
        $folderPath = $folder.FolderPath.Replace('/', '\')
        Write-Host "          Processing folder: $folderPath"

        # Check if the folder is excluded as defined in the $excludedFolders array
        $excludeFolder = $false
        foreach ($excludedFolder in $excludedFolders) {
            if ($folderPath -like $excludedFolder) {
                $excludeFolder = $true
                Write-Host "               Excluded"
                break
            }
        }

        # Get folder permissions
        if (-not $excludeFolder) {
            $folderPermUsers = Get-MailboxFolderPermission -Identity ($mailboxEmail + ":" + $folderPath) | Select-Object Identity, User, AccessRights
        
            # Iterate through each permission entry
            foreach ($entryUser in $folderPermUsers) {

                # Retrieve user email address if the user exists
                $userEmail = if ($entryUser.User -ne "Default" -and $entryUser.User -ne "Anonymous") { (Get-Recipient -Identity $entryUser.User -ErrorAction SilentlyContinue).PrimarySmtpAddress }

                # Store folder permissions in the array
                $mailboxPermData.Add([PSCustomObject]@{
                    Mailbox          = $mailboxEmail
                    Path             = $folderPath
                    ObjectType       = "Folder"
                    UserDisplayName  = $entryUser.User
                    UserEmailAddress = $userEmail
                    UserAccessRights = $entryUser.AccessRights
                }) | Out-Null
            }
        }
    }
}


# Organize aggregated permissions by mailbox and export to separate CSV files
$mailboxesData = $mailboxPermData | Group-Object -Property Mailbox
foreach ($mailboxData in $mailboxesData) {
    $mailboxEmail = $mailboxData.Name
    if ($mailboxData.Group.Count -gt 0) {
        $mailboxData.Group | Export-Csv -Path "$mailboxEmail - Mailbox Sharing Permissions.csv" -NoTypeInformation
    }
}

# Organize aggregated permissions by user email and export to separate CSV files
$usersData = $mailboxPermData | Group-Object -Property UserEmailAddress
foreach ($userData in $usersData) {
    $userEmail = $userData.Name
    if (-not [string]::IsNullOrWhiteSpace($userEmail) -and $userData.Group.Count -gt 0) {
        $userData.Group | Export-Csv -Path "$userEmail - Mailbox Access Permissions.csv" -NoTypeInformation
    }
}

# Disconnect from Exchange Online PowerShell
Disconnect-ExchangeOnline -Confirm:$false

Let me know if there’s anything I can do to speed up the processing time.

Thank you,
Duane

PS_noob,
Welcome to the forum. :wave:t3:

Nice Username! :smirk:

That’s a big chunk of code. I hope you don’t expect someone to review / refactor it in detail.

Some general tips:

  • If you have the suspition your code is not as fast as possible - measure it! It does not make sense to optimize if the piece of code you try to optimize is not the cause of the lack of speed.

  • If you have to query data from an external data source, make sure you only query the same data only once - not multiple times. If there is a chance you come around the same data twice or more query ALL possible data at once in advance and use a lookup table of these data later on. A quick overview over the code made me suspect your Get-Recipient calls are probably causing a lot of wasted speed.

  • If you have to write data to a slow target ( file system/external data store ) - do it once. A quick overview over the code made me suspect your Export-Csv calls inside a loop are probably causing a lot of wasted speed. Collect the data to write in a variable and write this variable at the end to the slow target.

! ! ! At least until now so called artificial intelligence tools are notorious for haluzinating. They need close guidance or supervision if you want to use them professionally. Especially if you ask them for code you should actually know the programming language you ask code for better than them to be able to see if the code make sense at all.

1 Like

If there are a large number of tenants, this stands out as a possible issue. Here is some explenation on why this can hinder performance:

My bad … as I look closer at your code, you are only using this when entering manual addresses. Sorry about that, however, still a good practice IMO. Have you looked at using the MSGraph API? Maybe ask AI to use that as an alternative.

long post incoming sorry!

Exchange admin here so this is definitely in my wheelhouse. I also happen to manage a pretty large (I think) environment. We’re probably north of 250K mailboxes and at least another 1-1.5K resource mailboxes and probably another 500-1000 bookings mailboxes.

Unfortunately, MS Graph doesn’t have great commands for Exchange. For example, that Stack overflow post , the one answer is incorrect, as the API endpoint suggested doesn’t replace Get-Mailbox at all. The Exchange management ps module is the best tool for the kind of work OP is doing.

To the OP:

If you are scanning every mailbox to get a list and have a big environment, it’s going to take a while. I hate to the bearer of bad news but there’s no getting around it, if you have a) a lot of mailboxes and b) plan on running all of that in a single script. One thing you could do is you already have a list of users from a different data source that you can load in relatively quickly, that might reduce the initial call. For example, perhaps in your environment you know that every user should have a mailbox. You can maybe use on prem Get-ADUser which is… going to be quicker to load your initial user data. From a quick glance (apologies if mistaken) That initial Get-Mailbox call isn’t really used in the data you put out, you seem to be using it to mostly make other calls later. If you happen to at least know which users should or shoulddn’t have mailboxes, you can skip that call (and of course document that it could be a bit inaccurate in some cases if your other source data doesn’t align your actual mailboxes). On that note, You might also consider filtering out certain types of mailboxes. For example, do you care about all mailboxes, or could you filter out some types? That would reduce some of the overhead, if you could do that. If nothing else, you could potentially use Graph to pull users emails in your tenant, and that would be faster to populate your initial list. Graph is pretty quick. It all depends on your underlying goal and target.

I think you might be doing too much in a single script. You’re going to need to split the script up or think about other ways of splitting up the work to accomplish data collection more quickly. You’re getting every single mailbox using Get-Mailbox (if email addresses aren’t provided), then you are calling a ton of other commands. You are calling Get-MailboxPermission, Get-MailboxFolderStatistics, Get-Recipient etc… and relatively speaking some of these take a long time, even for a single user. I hate to mention this but you’re also missing an important one… Get-RecipientPermission as that command will show you who has ‘send-as permissions’ on a mailbox, which based on your code, I’d think you’d want to include. All of these commands take time, and the more mailboxes you have, the more this scales out of control.

You might consider splitting the script up into multiple scripts that specialize in a certain thing. I don’t know enough about what you want and what you can do so it’s hard to suggest exactly how to split it up, but it’s definitely something to consider, if your use case would allow it. For example, you could split it up between what you refer to as ‘sharing permissions’ and ‘access’ permissions, and instead of having these in the same script, just… separate them out and run in two scripts instead. It may not be a huge time savings just to split it up in this way, but it’s a start. Get-MailboxPermission is basically a list of users ‘Full Access’ Permissions, whereas Get-MailboxFolderPermission deals with permission on each granular folder. Likewise Get-RecipientPermission generally deals with Send-As Permissions. This to me, is a logical way of splitting up the script.

There are other strategies you can take that could complicate your setup (using multiple runspaces and divying up the work between those), and you will inevitably run into rate limiting, so you’ll need to also consider what account or service principal that is connecting as a factor. You could paramtertize your script and start calling them with scheduled tasks, and based on the parameters, get a different subset of mailboxes and/or use a different account or app to do the work. If you ran these then in parallel (scheduled task runs at the same time) you can reduce your overall time. I don’t think the ForEach-Object -Parallel works for exchange online stuff unfortunately as those do run in different runspace. You could actually connect to exchange online in each of them, but you’ll definitely run into rate limitations on a single account. you could have better luck with an app and certificate based authentication, but even still you’re going to run into issues with rate limitations using a single app. you have to start scaling with more accounts/apps. Then you can start thinking about other ways to divy up the work to run that stuff in parallel. For example, maybe you split the work across 4-5 users, and they each are configured to run the script but contextually get a different source to start with, which allows you to divy up the mailboxes by a factor of 5. It’s all a matter of what sort of work are you willing to put in to get it to work.

Ok i’m gonna stop there as really that’s way too much information and I spent too long writing this :slight_smile:

Very informative. Thanks for taking the time :slight_smile:

Thank you, @tonyd . Since I’m calling all mailboxes at the beginning of the loop, I will see if I can get Chat GPT to help me replace the Get-Recipient call with a lookup table. That will certainly help.

For what it’s worth, the Export-Csv call writes different files each time through the loop, which is intentional.

Thank you for the feedback.

Thank you, @dotnVo . I appreciate the thorough examination.

More context: I work for a managed service provider, which means we work with a lot of different companies that have their own Microsoft 365 tenant. One of the most frequent requests we receive is something along the lines of, “please copy This User’s permissions to That User.” As you know, Microsoft does not provide a way to accomplish this through any GUI. Additionally, conversations about this topic sometimes lead to questions along the lines of, “who else is This User sharing their stuff with?”

Within a single company, these conversations might not happen frequently, but across multiple companies, they do. Since Microsoft doesn’t provide a view of everything a user can access across a tenant, it’s worth waiting for automation to provide a report because it would require far more time for a human to accomplish the same thing. This is why the script gets mailbox permissions AND folder permissions, and I will most certainly add the Send-As Permissions, thank you very much!

For what it’s worth, here is block of code that requires the most time:

    # Get folder statistics for the mailbox
    $folderStats = Get-MailboxFolderStatistics -Identity $mailboxEmail

    # Iterate through each folder in the mailbox
    foreach ($folder in $folderStats) {

        # Reformat path names
        $folderPath = $folder.FolderPath.Replace('/', '\')
        Write-Host "          Processing folder: $folderPath"

        # Check if the folder is excluded as defined in the $excludedFolders array
        $excludeFolder = $false
        foreach ($excludedFolder in $excludedFolders) {
            if ($folderPath -like $excludedFolder) {
                $excludeFolder = $true
                Write-Host "               Excluded"
                break
            }
        }

        # Get folder permissions
        if (-not $excludeFolder) {
            $folderPermUsers = Get-MailboxFolderPermission -Identity ($mailboxEmail + ":" + $folderPath) | Select-Object Identity, User, AccessRights
        
            # Iterate through each permission entry
            foreach ($entryUser in $folderPermUsers) {

                # Retrieve user email address if the user exists
                $userEmail = if ($entryUser.User -ne "Default" -and $entryUser.User -ne "Anonymous") { (Get-Recipient -Identity $entryUser.User -ErrorAction SilentlyContinue).PrimarySmtpAddress }

                # Store folder permissions in the array
                $mailboxPermData.Add([PSCustomObject]@{
                    Mailbox          = $mailboxEmail
                    Path             = $folderPath
                    ObjectType       = "Folder"
                    UserDisplayName  = $entryUser.User
                    UserEmailAddress = $userEmail
                    UserAccessRights = $entryUser.AccessRights
                }) | Out-Null
            }
        }
    }

As mentioned in my previous reply to @tonyd , I intend to replace the Get-Recipient call with some kind of lookup table solution. While this will save time, it seems that there’s no way to reduce the time required for each Get-MailboxFolderStatistics call.

Since the most valuable part of this script is collecting a user’s permissions on every folder of every mailbox, I’m gathering from your reply that Microsoft’s current design is simply going to require a long waiting period to extract the data I am looking for.

Thank you for taking the time to examine my code and lend your expertise. Even though I am not finding the answers I’m looking for, it is validating to know that this script is about the best that can be accomplished under the circumstances.

Gah. I really do feel for you. I hate these requests, and as an MSP you come in and have no idea how these companies have operationally handled things in the past. It’s almost always a mess and folks don’t understand the implications of their decisions, they are just ‘getting the requests done’, It turns into an absolute mess for folks who take over.

While I’ve worked for the same university for a long time, IT is generally decentralized in higher education. Each team I’ve worked on that relied on any sort of ‘windows’ security, I’ve implemented security groups (role/resource groups) to maintain security on things. Exchange is no different. Someone needs ‘full access to an account’? A group following a strict naming convention is created, mail-enabled, and is applied. Users who need access go into that group. Better yet, you have ‘role groups’ that are added to ‘resource groups’ and each ‘resource’ group is only applied to a single resource, and you can start to scale a lot easier. It’s something that works but takes effort to setup. Mostly sharing as this might be a solution you could propose and work towards, espeically if you get a solid intake/outtake process to work with it.

Yeah, if you absolutely have to comb through every folder on a mailbox and report back it’s data along with it’s permissions, that’s going to take time.

Just for me, that command alone takes 5-6 seconds for my own account. That doesn’t seem like much, but let’s say you have 100K accounts… and it takes 5 seconds. Welp that’s 500K seconds… and… well you can do the math :slight_smile: .

We try to avoid this level of granular permission in our environment. We find it’s almost never actually needed and its usually some super edge case, which are documented. Typically if someone needs access to a mailbox, we find they generally need access to the whole thing, and if there’s data they need access in, in a single folder, there’s probably a better process for storing whatever one off data/message they need, and granting access to the folder perhaps isn’t the move. As an exchange environment grows (more mailboxes), that particular call is costly, and I am pretty sure the more folders/subfolders a person has, the longer that call takes. If this is common practice (people getting access to the top of folder store, then folders below), managing it with groups could also be a potential nightmare.

The clients I work with are typically on the smaller side, less than 100 users per tenant. Most are even 50 or fewer, while some will creep up into the 200- or 300-user range, but that’s few and far between. Also, it’s unfortunately rare for small companies to effectively deploy group-based permissions, let alone with a scalable design. I’m talking about legal firms, financial management, property management, venture capital, startup companies, and similar small businesses. Typically, an assistant is tasked with managing calendars for individuals and resources, and for privacy reasons absolutely must not have full access to the rest of the user mailboxes. But then there are frequent exceptions, such as assistants who have built implicit trust from their boss, and they might get access to most if not everything. In a single company, exceptions do tend to be rare, but in MSP-Land where we might have a ratio of 1 IT person per 25 companies, that’s A LOT of exceptions. Nobody has the time to overhaul an individual company’s permissions structure and deploy a design based on best practices because we’re all too busy handling the day-to-day. Not to mention, having conversations around the proverbial watercooler and in forums like this about how much time we don’t have. LOL

Anyway, thank you for all the input. Cheers!