Delete specific emails from Office 365 inboxes in customer tenants via PowerShell
There are plenty of scenarios where it can be handy to retrieve and delete specific emails from user mailboxes. And while Microsoft provides an excellent method to do this, it doesn’t address every scenario.
This script is designed for Microsoft Partners or Office 365 admins who need to quickly script or automate the removal of certain emails from their own and delegated customers’ user mailboxes without having to assign specific roles.
Please note that this script should be used with a high degree of caution and consideration.
Microsoft Partners can use this script to retrieve and delete emails that match a certain criteria from all user mailboxes in all customer tenants. For instance, if a bad actor is sending a targeted phishing email to multiple customer tenants, you can use this script to retrieve and delete the messages from all customer tenants at once.
- Step 1 – Create the application with permission to access customer tenants mailboxes
- Step 2 – Edit the script to select the emails that you want to remove, export them to a CSV, and confirm removal.
Create an Azure AD Application with permission to access emails in your own and your customers’ tenants
- Double click the below script to select it.
- Copy and paste the script into a new file in Visual Studio Code and save it with a .ps1 extension
- Install the recommended PowerShell module if you haven’t already
- Modify the $homePage and $logoutURI values to any valid URIs that you like. They don’t need to be actual addresses, so feel free to make something up. Set the $appIDUri variable to a use a valid domain in your tenant. eg. https://yourdomain.com/$((New-Guid).ToString())
- If you are running the script for a single tenant, comment out the following two lines:
- Press F5 to run the script
- Sign in to Azure AD using your global admin credentials. Note that the login window may appear behind Visual Studio Code. The following screenshots are examples, so don’t worry if the Permissions/App names in the screenshot don’t match up.
- Wait for the script to complete.
- Retrieve the client ID, client secret and tenant ID from the exported CSV at C:\temp\azureadapp.csv. (below image is just an example.)
PowerShell Script to create and authorise an application to access mailboxes in customer tenants
# This script needs to be run by an admin account in your Office 365 tenant # This script will create an Azure AD app in your organisation with permission # to access resources in yours and your customers' tenants. # It will export information about the application to a CSV located at C:\temp\. # The CSV will include the Client ID and Secret of the application, so keep it safe. # Confirm C:\temp exists $temp = Test-Path -Path C:\temp if ($temp) { #Write-Host "Path exists" } else { Write-Host "Creating Temp folder" New-Item -Path C:\temp -ItemType directory } $applicationName = "GCITS Mailbox Worker" # Change this to true if you would like to overwrite any existing applications with matching names. $removeExistingAppWithSameName = $false # Modify the homePage, appIdURI and logoutURI values to whatever valid URI you like. # They don't need to be actual addresses, so feel free to make something up. $homePage = "https://secure.gcits.com" $appIdURI = "https://secure.gcits.com/$((New-Guid).ToString())" $logoutURI = "https://portal.office.com" $URIForApplicationPermissionCall = "https://graph.microsoft.com/v1.0/users" $ApplicationPermissions = "Directory.Read.All MailboxSettings.ReadWrite Mail.ReadWrite" Function Add-ResourcePermission($requiredAccess, $exposedPermissions, $requiredAccesses, $permissionType) { foreach ($permission in $requiredAccesses.Trim().Split(" ")) { $reqPermission = $null $reqPermission = $exposedPermissions | Where-Object { $_.Value -contains $permission } Write-Host "Collected information for $($reqPermission.Value) of type $permissionType" -ForegroundColor Green $resourceAccess = New-Object Microsoft.Open.AzureAD.Model.ResourceAccess $resourceAccess.Type = $permissionType $resourceAccess.Id = $reqPermission.Id $requiredAccess.ResourceAccess.Add($resourceAccess) } } Function Get-RequiredPermissions($requiredDelegatedPermissions, $requiredApplicationPermissions, $reqsp) { $sp = $reqsp $appid = $sp.AppId $requiredAccess = New-Object Microsoft.Open.AzureAD.Model.RequiredResourceAccess $requiredAccess.ResourceAppId = $appid $requiredAccess.ResourceAccess = New-Object System.Collections.Generic.List[Microsoft.Open.AzureAD.Model.ResourceAccess] if ($requiredDelegatedPermissions) { Add-ResourcePermission $requiredAccess -exposedPermissions $sp.Oauth2Permissions -requiredAccesses $requiredDelegatedPermissions -permissionType "Scope" } if ($requiredApplicationPermissions) { Add-ResourcePermission $requiredAccess -exposedPermissions $sp.AppRoles -requiredAccesses $requiredApplicationPermissions -permissionType "Role" } return $requiredAccess } Function New-AppKey ($fromDate, $durationInYears, $pw) { $endDate = $fromDate.AddYears($durationInYears) $keyId = (New-Guid).ToString() $key = New-Object Microsoft.Open.AzureAD.Model.PasswordCredential($null, $endDate, $keyId, $fromDate, $pw) return $key } Function Test-AppKey($fromDate, $durationInYears, $pw) { $testKey = New-AppKey -fromDate $fromDate -durationInYears $durationInYears -pw $pw while ($testKey.Value -match "\+" -or $testKey.Value -match "/") { Write-Host "Secret contains + or / and may not authenticate correctly. Regenerating..." -ForegroundColor Yellow $pw = Initialize-AppKey $testKey = New-AppKey -fromDate $fromDate -durationInYears $durationInYears -pw $pw } Write-Host "Secret doesn't contain + or /. Continuing..." -ForegroundColor Green $key = $testKey return $key } Function Initialize-AppKey { $aesManaged = New-Object "System.Security.Cryptography.AesManaged" $aesManaged.Mode = [System.Security.Cryptography.CipherMode]::CBC $aesManaged.Padding = [System.Security.Cryptography.PaddingMode]::Zeros $aesManaged.BlockSize = 128 $aesManaged.KeySize = 256 $aesManaged.GenerateKey() return [System.Convert]::ToBase64String($aesManaged.Key) } function Confirm-MicrosoftGraphServicePrincipal { $graphsp = Get-AzureADServicePrincipal -All $true | Where-Object {$_.displayname -eq "Microsoft Graph"} if (!$graphsp) { $graphsp = Get-AzureADServicePrincipal -SearchString "Microsoft.Azure.AgregatorService" } if (!$graphsp) { Login-AzureRmAccount -Credential $credentials New-AzureRmADServicePrincipal -ApplicationId "00000003-0000-0000-c000-000000000000" $graphsp = Get-AzureADServicePrincipal -All $true | Where-Object {$_.displayname -eq "Microsoft Graph"} } return $graphsp } Write-Host "Connecting to Azure AD. The login window may appear behind Visual Studio Code." Connect-AzureAD Write-Host "Creating application in tenant: $((Get-AzureADTenantDetail).displayName)" # Check for the Microsoft Graph Service Principal. If it doesn't exist already, create it. $graphsp = Confirm-MicrosoftGraphServicePrincipal $existingapp = $null $existingapp = Get-AzureADApplication -SearchString $applicationName if ($existingapp -and $removeExistingAppWithSameName) { Remove-AzureADApplication -ObjectId $existingApp.objectId } # RSPS $rsps = @() if ($graphsp) { $rsps += $graphsp $tenant_id = (Get-AzureADTenantDetail).ObjectId $tenantName = (Get-AzureADTenantDetail).DisplayName # Add Required Resources Access (Microsoft Graph) $requiredResourcesAccess = New-Object System.Collections.Generic.List[Microsoft.Open.AzureAD.Model.RequiredResourceAccess] $microsoftGraphRequiredPermissions = Get-RequiredPermissions -reqsp $graphsp -requiredApplicationPermissions $ApplicationPermissions -requiredDelegatedPermissions $DelegatedPermissions $requiredResourcesAccess.Add($microsoftGraphRequiredPermissions) # Get an application key $pw = Initialize-AppKey $fromDate = [System.DateTime]::Now $appKey = Test-AppKey -fromDate $fromDate -durationInYears 99 -pw $pw Write-Host "Creating the AAD application $applicationName" -ForegroundColor Blue $aadApplication = New-AzureADApplication -DisplayName $applicationName ` -HomePage $homePage ` -ReplyUrls $homePage ` -IdentifierUris $appIdURI ` -LogoutUrl $logoutURI ` -RequiredResourceAccess $requiredResourcesAccess ` -PasswordCredentials $appKey ` -AvailableToOtherTenants $true # Creating the Service Principal for the application $servicePrincipal = New-AzureADServicePrincipal -AppId $aadApplication.AppId Write-Host "Assigning Permissions" -ForegroundColor Yellow # Assign application permissions to the application foreach ($app in $requiredResourcesAccess) { $reqAppSP = $rsps | Where-Object { $_.appid -contains $app.ResourceAppId } Write-Host "Assigning Application permissions for $($reqAppSP.displayName)" -ForegroundColor DarkYellow foreach ($resource in $app.ResourceAccess) { if ($resource.Type -match "Role") { New-AzureADServiceAppRoleAssignment -ObjectId $serviceprincipal.ObjectId ` -PrincipalId $serviceprincipal.ObjectId -ResourceId $reqAppSP.ObjectId -Id $resource.Id } } } # This provides the application with access to your customer tenants. $group = Get-AzureADGroup -Filter "displayName eq 'Adminagents'" Add-AzureADGroupMember -ObjectId $group.ObjectId -RefObjectId $servicePrincipal.ObjectId Write-Host "App Created" -ForegroundColor Green # Define parameters for Microsoft Graph access token retrieval $client_id = $aadApplication.AppId; $client_secret = $appkey.Value $tenant_id = (Get-AzureADTenantDetail).ObjectId $resource = "https://graph.microsoft.com" $authority = "https://login.microsoftonline.com/$tenant_id" $tokenEndpointUri = "$authority/oauth2/token" # Get the access token using grant type password for Delegated Permissions or grant type client_credentials for Application Permissions $content = "grant_type=client_credentials&client_id=$client_id&client_secret=$client_secret&resource=$resource" # Try to execute the API call 6 times $Stoploop = $false [int]$Retrycount = "0" do { try { $response = Invoke-RestMethod -Uri $tokenEndpointUri -Body $content -Method Post -UseBasicParsing Write-Host "Retrieved Access Token" -ForegroundColor Green # Assign access token $access_token = $response.access_token $body = $null $body = Invoke-RestMethod ` -Uri $UriForApplicationPermissionCall ` -Headers @{"Authorization" = "Bearer $access_token" } ` -ContentType "application/json" ` -Method GET ` Write-Host "Retrieved Graph content" -ForegroundColor Green $Stoploop = $true } catch { if ($Retrycount -gt 5) { Write-Host "Could not get Graph content after 6 retries." -ForegroundColor Red $Stoploop = $true } else { Write-Host "Could not get Graph content. Retrying in 5 seconds..." -ForegroundColor DarkYellow Start-Sleep -Seconds 5 $Retrycount ++ } } } While ($Stoploop -eq $false) $appInfo = [pscustomobject][ordered]@{ ApplicationName = $ApplicationName TenantName = $tenantName TenantId = $tenant_id clientId = $client_id clientSecret = $client_secret ApplicationPermissions = $ApplicationPermissions } $AppInfo | Export-Csv C:\temp\AzureADApp.csv -Append -NoTypeInformation } else { Write-Host "Microsoft Graph Service Principal could not be found or created" -ForegroundColor Red }
Removing specific emails in Office 365 via PowerShell
Preparing the Office 365 email deletion PowerShell script
- Copy and paste the below script into Visual Studio Code and save it with a .ps1 extension.
- Retrieve the Tenant ID, Client ID and Client Secret from the C:\temp\AzureADApp.csv file created by the previous script. Paste these values into the $ourTenantId, $clientId and $clientSecret values at the top of the second script.
Define the queries for the emails you would like to delete
In this case, we are retrieving emails sent to and from a specific address using filter and search queries located on lines 79 and 80 of the script. However you can use the odata queries on the Microsoft Graph to filter on whatever you like. See here for more information: https://docs.microsoft.com/en-us/graph/query-parameters
The filter query for emails sent from a particular address looks like this:
"users/$($user.userPrincipalName)/messages?`$filter=from/emailAddress/address+eq+'[email protected]'"
The search query for emails sent to a particular address looks like this:
"users/$($user.userPrincipalName)/messages?`$search=`"recipients:[email protected]`""
Before running the script, be sure to update the queries to retrieve the emails that you want to delete.
Note that while we are performing two queries in separate calls in the example, you can combine multiple queries in a single call to save time.
- When you’re happy with your queries, press F5 to run the script
- If you’re a Microsoft Partner with delegated access to customer tenants, you can select the tenant or tenants that you would like to search. Note that the grid view window may appear behind Visual Studio Code.
- The script will loop through the specified tenants and user mailboxes and search for emails matching your criteria. Some users may not have mailboxes and will display a ‘couldn’t access message’.
- Once the script has completed searching your mailboxes, it will export a CSV containing a list of emails to C:\temp\pendingDeletion.csv
- You can review the CSV to confirm that you would like to delete the specified emails.
- After reviewing the CSV and removing any entries that you don’t want to delete, enter ‘y’ to confirm deletion and wait for the script to complete.
PowerShell script to delete specific emails from Office 365 tenants
$clientId = "Enter_Client_ID_Here" $clientSecret = "Enter_Client_Secret_Here" $ourTenantId = "Enter_Your_Tenant_ID_Here" function Get-GCITSAccessToken($appCredential, $tenantId) { $client_id = $appCredential.appID $client_secret = $appCredential.secret $tenant_id = $tenantid $resource = "https://graph.microsoft.com" $authority = "https://login.microsoftonline.com/$tenant_id" $tokenEndpointUri = "$authority/oauth2/token" $content = "grant_type=client_credentials&client_id=$client_id&client_secret=$client_secret&resource=$resource" $response = Invoke-RestMethod -Uri $tokenEndpointUri -Body $content -Method Post -UseBasicParsing $access_token = $response.access_token return $access_token } function Get-GCITSMSGraphResource($Resource) { $graphBaseUri = "https://graph.microsoft.com/v1.0" $values = @() $result = Invoke-RestMethod -Uri "$graphBaseUri/$resource" -Headers $headers if ($result.value) { $values += $result.value if ($result."@odata.nextLink") { do { $result = Invoke-RestMethod -Uri $result."@odata.nextLink" -Headers $headers $values += $result.value } while ($result."@odata.nextLink") } } else { $values = $result } return $values } $appCredential = @{ AppId = $clientId Secret = $clientSecret } $accessToken = Get-GCITSAccessToken -appCredential $appCredential -tenantId $ourTenantId $headers = @{ Authorization = "Bearer $accesstoken" } $ourTenantInfo = Get-GCITSMSGraphResource -Resource organization $customers = @() $customers += [pscustomobject][ordered]@{ customerid = $ourtenantid defaultDomainName = ($ourTenantInfo.verifiedDomains | Where-Object { $_.isinitial }).name displayName = $ourTenantInfo.displayName } try { $customers += Get-GCITSMSGraphResource -Resource contracts } catch { Write-Host "Couldn't retrieve any customer tenants" } if (($customers | Measure-Object).count -gt 1) { $customers = $customers | Out-GridView -PassThru } $emailCollection = @() foreach ($customer in $customers) { Write-Host "Searching $($customer.displayname)" -foregroundcolor blue $accessToken = Get-GCITSAccessToken -appCredential $appCredential -tenantId $customer.customerid $headers = @{ Authorization = "Bearer $accessToken" } $users = Get-GCITSMSGraphResource -Resource users | Where-Object { $_.userPrincipalName -notmatch "#EXT#" } foreach ($user in $users) { $usersEmails = @() try { # Modify the below queries to retrieve emails that you would like to delete - eg emails sent and received from [email protected] $usersEmails += Get-GCITSMSGraphResource -Resource "users/$($user.userPrincipalName)/messages?`$filter=from/emailAddress/address+eq+'[email protected]'" $usersEmails += Get-GCITSMSGraphResource -Resource "users/$($user.userPrincipalName)/messages?`$search=`"recipients:[email protected]`" " $count = ($usersEmails.id | Measure-Object).count if ($count -gt 0) { Write-Host "Searched mailbox: $($user.userPrincipalName). Found $count matching emails" -ForegroundColor green } else { Write-Host "Searched mailbox: $($user.userPrincipalName). No matches." -ForegroundColor darkgreen } } catch { Write-Host "Couldn't access: $($user.userprincipalname)" -ForegroundColor yellow } if ($usersEmails) { $usersEmails | Add-Member UserPrincipalName $user.userPrincipalName -Force $usersEmails | Add-Member TenantId $customer.customerId $usersEmails | Add-Member CustomerName $customer.displayName $emailCollection += $usersEmails } } } $emails = $emailCollection | Where-Object { $_.id } $summary = $emails | Select-Object @{n = "CustomerName"; e = { $_.CustomerName } }, SentDateTime, ` @{n = "FromEmail"; e = { $_.from.emailAddress.address } }, @{n = "ToEmail"; e = { ($_.toRecipients.emailAddress | ForEach-Object { $_.address }) -join ", " } }, ` @{n = "ccEmail"; e = { ($_.ccRecipients.emailAddress | ForEach-Object { $_.address }) -join ", " } }, ` @{n = "bccEmail"; e = { ($_.bccRecipients.emailAddress | ForEach-Object { $_.address }) -join ", " } }, ` Subject, @{n = "inMailbox"; e = { $_.userPrincipalName } }, id, @{n = "TenantId"; e = { $_.TenantId } } $summary | Export-Csv C:\temp\PendingDeletion.csv -NoTypeInformation $confirmDeletion = Read-Host "A list of emails for deletion has been exported to C:\temp\PendingDeletion. Please review and edit this CSV and confirm deletion by entering 'Y'" if ($confirmDeletion.trim() -eq "y") { $toDelete = Import-Csv C:\temp\PendingDeletion.csv $groups = $toDelete | Group-Object TenantId foreach ($group in $groups) { Write-Host "Deleting emails from $($group.group.customername | Select-Object -First 1)" $accessToken = Get-GCITSAccessToken -appCredential $appCredential -tenantId $group.Name $headers = @{ Authorization = "Bearer $accessToken" } foreach ($email in $group.group) { Write-Host "Deleting email from $($email.inmailbox) - Subject: $($email.subject)" -ForegroundColor yellow $delete = Invoke-RestMethod -Method Delete -Uri "https://graph.microsoft.com/v1.0/users/$($email.inMailbox)/messages/$($email.id)" -Headers $headers } } }
Leave a Reply
Want to join the discussion?Feel free to contribute!