Advanced 365 Mailbox management with MS Graph, Powershell, and JSON

Create Office 365 mailbox folders and advanced mailbox rules with Powershell and MS Graph

Recently, a request came through to create some email messaging processing for various Office 365 users, which involved creating some folder structure in mailboxes of a list of users, and creating a message rule that had some different criteria based on email message attributes.

The needs were:

  • Create a root folder
  • Create a folder inside that root folder
  • Create a rule:
    • Check the message Header for From address
    • Check for custom Header information
    • Move message to previously created subfolder
  • Finally, make sure there was no second mailbox rule created if already existed.

The New-MailboxFolder Powershell command works perfectly if the folder needed is for your own mailbox, if you want to run it against others, there is no current Powershell commandlet, so custom code must be created. While there are some basic examples out there, there was no comprehensive script anyone has published as of yet, so here is one I came up with.

For brevity purposes, I won’t go into detail the process that’s required to authenticate in order to run scripts against your environment, as there are quite a few resources available easily found by your favorite search engine, so I will skip over that process, and explain the “how to figure out what you need to accomplish with Powershell using MS Graph.”

$Mailboxes = @("userEmail.domain.com")
$Folders = @("RootFolder","OtherRootFolder")
$SubFolders = @("SubFolder")
$MailbRule = "RuleForSubFolder"

$AppId = "xxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx"
$AppSecret = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
$Scope = "https://graph.microsoft.com/.default"
$TenantName = "xxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx"

$Url = "https://login.microsoftonline.com/$TenantName/oauth2/v2.0/token"

Add-Type -AssemblyName System.Web

$Body = @{
    client_id = $AppId
	client_secret = $AppSecret
	scope = $Scope
	grant_type = 'client_credentials'
}

$PostSplat = @{
    ContentType = 'application/x-www-form-urlencoded'
    Method = 'POST'
    # Create string by joining bodylist with '&'
    Body = $Body
    Uri = $Url
}

$Request = Invoke-RestMethod @PostSplat

$Header = @{
    Authorization = "$($Request.token_type) $($Request.access_token)"
}

foreach($Mailbox in $Mailboxes) {

    $Uri = "https://graph.microsoft.com/v1.0/users/$Mailbox/mailFolders"
 
    $Mailboxfolders = Invoke-RestMethod -Uri $Uri -Headers $Header -Method Get -ContentType "application/json"
    $MailboxfoldersList = $Mailboxfolders.value.displayName
    $NextPage = $Mailboxfolders.'@Odata.NextLink'
   
    While($null -ne $NextPage) {
        $Mailboxfolders = Invoke-RestMethod -Uri $NextPage -Headers $Header -Method Get -ContentType "application/json"
        $MailboxfoldersList += $Mailboxfolders.value.displayName
        $NextPage = $Mailboxfolders.'@Odata.NextLink'
    }

    foreach($Folder in $Folders) {
        $Body = @"
        {
            "displayName": "$Folder"
        }
"@
        Write-Host "Mailbox: $Mailbox`nMailboxfolders: $($MailboxfoldersList)`nFolder wanted: $Folder"

        if($($MailboxfoldersList) -contains $Folder) {
            Write-Host "$Folder folder already found at mailbox $Mailbox, creating subfolder.`n"
            $UriParent = "https://graph.microsoft.com/v1.0/users/$Mailbox/mailFolders/?`$filter=displayname eq '$Folder'"
            $ParentFolder = Invoke-RestMethod -Uri $UriParent -Headers $Header -Method Get -ContentType "application/json"
            $UriSub = "https://graph.microsoft.com/v1.0/users/$Mailbox/mailFolders/$($ParentFolder.value.id)/childFolders"
        }

        else {
            $ParentFolder = Invoke-RestMethod -Uri $Uri -Headers $Header -Method Post -Body $Body -ContentType "application/json"
            Write-Host "Created new folder: $($ParentFolder.displayName) to mailbox $Mailbox!`n"
            $UriSub = "https://graph.microsoft.com/v1.0/users/$Mailbox/mailFolders/$($ParentFolder.value.id)/childFolders"
        }
    }
    
    $MailboxSubfolders = Invoke-RestMethod -Uri $UriSub -Headers $Header -Method Get -ContentType "application/json"
    $MailboxSubfoldersList = $MailboxSubfolders.value.displayName
    
    foreach($SubFolder in $SubFolders) {
        $Body2 = @"
        {
            "displayName": "$SubFolder"
        }
"@
        if($($MailboxSubfoldersList) -contains $Subfolder) {
            Write-Host "$Subfolder folder already found at mailbox $Mailbox.`n"
            $UriGetSub = "https://graph.microsoft.com/v1.0/users/$Mailbox/mailFolders/$($ParentFolder.value.id)/childFolders/?`$filter=displayname eq '$Subfolder'"
            $SubId = Invoke-RestMethod -Uri $UriGetSub -Headers $Header -Method Get -ContentType "application/json"
            
            $UriGetRules = "https://graph.microsoft.com/v1.0/users/$Mailbox/mailFolders/inbox/messageRules"

            $MailboxRules = Invoke-RestMethod -Uri $UriGetRules -Headers $Header -Method Get -ContentType "application/json"
            Write-Host "The rules are: $($MailboxRules.value.displayName)"
            $MailboxRulesList = $MailboxRules.value.displayName

            if($($MailboxRulesList) -contains "$MailbRule") {
                Write-Host "The mailbox rule $MailbRule already found at mailbox $Mailbox.`n"
                break
            }
            else {

                ## For syntax: https://developer.microsoft.com/en-us/graph/graph-explorer
                $RuleBody = @"
                {
                    "displayName": "$MailbRule",
                    "sequence": 2,
                    "isEnabled": true,
                    "conditions": {
                        "headerContains": [
                            "X-SomeCompany-tag: customTag"
                        ]
                    },
                    "actions": {
                        "moveToFolder": "$($SubId.id)",
                        "stopProcessingRules": true
                    },
                    "exceptions": {
                        "headerContains": [
                            "X-SomeCompany-Spam-Reason: eusafe",
                            "Reply-To: noreply@somedomain.com"
                        ]
                    }
                }
"@
                $RuleUri = "https://graph.microsoft.com/v1.0/users/$Mailbox/mailFolders/inbox/messageRules"
                $NewRule = Invoke-RestMethod -Uri $RuleUri -Headers $Header -Method Post -Body $RuleBody -ContentType "application/json"
                Write-Host "Created new Rule: $MailbRule in mailbox $Mailbox!`n"
            }
        }
        else {
            
            $NewSubfolder = Invoke-RestMethod -Uri $UriSub -Headers $Header -Method Post -Body $Body2 -ContentType "application/json"
            Write-Host "Created new subfolder: $($NewSubfolder.displayName) in $Folder to mailbox $Mailbox!`n"

            $RuleBody = @"
            {
                "displayName": "$MailbRule",
                "sequence": 2,
                "isEnabled": true,
                "conditions": {
                    "headerContains": [
                        "X-SomeCompany-tag: customTag"
                    ]
                },
                "actions": {
                    "moveToFolder": "$($NewSubfolder.id)",
                    "stopProcessingRules": true
                },
                "exceptions": {
                    "headerContains": [
                        "X-SomeCompany-Spam-Reason: eusafe",
                        "Reply-To: noreply@somedomain.com"
                    ]
                }
            }
"@
            $RuleUri = "https://graph.microsoft.com/v1.0/users/$Mailbox/mailFolders/inbox/messageRules"

            $NewRule = Invoke-RestMethod -Uri $RuleUri -Headers $Header -Method Post -Body $RuleBody -ContentType "application/json"
            Write-Host "Created new Rule: $MailbRule in mailbox $Mailbox!`n"
        }
    }
}

The key part of this article is not to show how fancy of a script I can write (disclaimer: the fancy spacing is from Visual Studio Code, use it!), but rather, how to get at the MS Graph API and syntax required to do the tremendous amount of capabilities that it’s got access to. I figured by throwing up a script that does quite a few different things that were previously only available if you ran several different scripts one after another (and hoped nothing broke), here’s an example of doing several different things easily using Powershell against the MS Graph API.

To see what JSON I needed, I used https://developer.microsoft.com/en-us/graph/graph-explorer extensively to see the fields to use, and looked up the REST API documentation to see what properties are required in the request body. (For example, for the Message Rule, I went to: https://docs.microsoft.com/en-us/graph/api/mailfolder-post-messagerules)

Add-AzureADGroupMember tricks when UPN is different than Primary Email address

I recently had to add a bunch of users to an AzureAD group where the UserPrincipalName was different than the user account, thus causing all sorts of failures when adding it in the PowerShell CLI as well as the bulk add from the Azure web portal.

Get-AzureADUser has some examples on grabbing a user, but I wanted to point out the “-Filter” parameter is an oData v3.0 filter statement. https://www.odata.org/documentation/odata-version-3-0/odata-version-3-0-core-protocol/

What does that mean? This is the example from the doc page:

Get-AzureADUser -Filter "userPrincipalName eq 'jondoe@contoso.com'"

What I wanted to point out is that you can use any of the ExtensionProperties that the user account contains.

For instance, here’s the script I threw together to add users to a group based on their “mail” property:

 $imp1 = Import-Csv C:\users\luceds\desktop\exp1.csv
 ForEach ($line in $imp1)
   {
     $mem1 = Get-AzureADUser -Filter "mail eq '$($line.UPN)'"
     $mem1 # drop the name on the screen to check for errors
   Add-AzureADGroupMember -ObjectId 0c3ac25f-449b-4057-bd16-826269exxxxx -RefObjectId $mem1.ObjectId
 } 

The “queryingcollections” section in the oData document page show the syntax that’s possible for the -Filter parameter

https://www.odata.org/documentation/odata-version-3-0/odata-version-3-0-core-protocol/#queryingcollections

Unable to move failed-over-to-DR databases back to production Site

I recently came across a scenario, where an Exchange environment that had been configured in a Best Practice state had failed over to the DR site due to an extended network outage at the primary production site, and was unable to re-seed back and fail back over.

The environment was configured very similar as described in the Deploy HA documentation by Microsoft, and had it’s DAG configured across two sites:

Stock example showing DR site relationship

Instead of the “Replication” network that is shown in the above graphic, the primary site had a secondary network (subnet 192.168.100.x) where DPM backup services ran on, the DR site did not include a secondary network.

Although the Exchange databases were mounted and running on the DR server infrastructure, the replication state was in a failed state at the primary site. Running a Get-MailboxDatabaseCopyStatus command showed all databases in a status of DisconnectedAndResynchronizing

DisconnectedAndResynchronizing state

All steps attempted to try to re-establish synchronization of the databases failed with various different error messages, even deleting the existing database files and trying to re-seed the databases failed, with most messages pointing to network connectivity issues.

Update-MailboxDatabaseCopy vqmbd06\pcfexch006 -DeleteExistingFiles

Confirm
Are you sure you want to perform this action?
Seeding database copy "VQMBD06\PCFEXCH006".
[Y] Yes  [A] Yes to All  [N] No  [L] No to All  [?] Help (default is "Y"):
The seeding operation failed. Error: An error occurred while performing the seed operation. Error: An error occurred
while communicating with server 'DCExchange'. Error: A socket operation was attempted to an unreachable network
10.50.3.15:64327 [Database: VQMBD06, Server: pcfexch006.xxxxx.com]
    + CategoryInfo          : InvalidOperation: (:) [Update-MailboxDatabaseCopy], SeedInProgressException
    + FullyQualifiedErrorId : [Server=PCFEXCH006,RequestId=e0740b4a-7b94-42f5-b3ad-7ee42632f9c4]
 [FailureCategory=Cmdlet-SeedInProgressException] 2D10AE04,Microsoft.Exchange.Management.SystemConfigurationTasks.UpdateDatabaseCopy
    + PSComputerName        : pcfexch006.xxxxx.com

Looking carefully at the error message, the error says: A socket operation was attempted to an unreachable network 10.50.3.15:64327

Very strange, as when a network test was run, no errors occurred with connecting to that IP and TCP port.

Test-NetConnection -ComputerName DCExchange -Port 64327


ComputerName     : DCExchange
RemoteAddress    : 10.50.3.15
RemotePort       : 64327
InterfaceAlias   : Ethernet
SourceAddress    : 10.50.2.42
TcpTestSucceeded : True

When the test command Test-ReplicationHealth was run, the ClusterNetwork state was in a failed state:

PCFEXCH006      ClusterNetwork             *FAILED*   On server 'PCFEXCH006' there is more than one network interface
                                                      configured for registration in DNS. Only the interface used for
                                                      the MAPI network should be configured for DNS registration.
                                                      Network 'MapiDagNetwork' has more than one network interface for
                                                      server 'pcfexch006'. Correct the physical network configuration
                                                      so that each Mailbox server has exactly one network interface
                                                      for each subnet you intend to use. Then use the
                                                      Set-DatabaseAvailabilityGroup cmdlet with the -DiscoverNetworks
                                                      parameters to reconfigure the database availability group
                                                      networks.
                                                      Subnet '10.50.3.0/24' on network 'MapiDagNetwork' is not Up.
                                                      Current state is 'Misconfigured'.
                                                      Subnet '10.50.3.0/24' on network 'MapiDagNetwork' is not Up.
                                                      Current state is 'Misconfigured'.
                                                      Subnet '10.50.3.0/24' on network 'MapiDagNetwork' is not Up.
                                                      Current state is 'Misconfigured'.
                                                      Subnet '10.50.3.0/24' on network 'MapiDagNetwork' is not Up.
                                                      Current state is 'Misconfigured'.
                                                      Subnet '10.50.3.0/24' on network 'MapiDagNetwork' is not Up.
                                                      Current state is 'Misconfigured'.
                                                      Subnet '192.168.100.0/24' on network 'MapiDagNetwork' is not Up.
                                                       Current state is 'Misconfigured'.
                                                      Subnet '192.168.100.0/24' on network 'MapiDagNetwork' is not Up.
                                                       Current state is 'Misconfigured'.
                                                      Subnet '192.168.100.0/24' on network 'MapiDagNetwork' is not Up.
                                                       Current state is 'Misconfigured'.
                                                      Subnet '192.168.100.0/24' on network 'MapiDagNetwork' is not Up.
                                                       Current state is 'Misconfigured'.
                                                      Subnet '192.168.100.0/24' on network 'MapiDagNetwork' is not Up.
                                                       Current state is 'Misconfigured'.
                                                      Subnet '10.50.2.0/24' on network 'MapiDagNetwork' is not Up.
                                                      Current state is 'Misconfigured'.
                                                      Subnet '10.50.2.0/24' on network 'MapiDagNetwork' is not Up.
                                                      Current state is 'Misconfigured'.
                                                      Subnet '10.50.2.0/24' on network 'MapiDagNetwork' is not Up.
                                                      Current state is 'Misconfigured'.
                                                      Subnet '10.50.2.0/24' on network 'MapiDagNetwork' is not Up.
                                                      Current state is 'Misconfigured'.
                                                      Subnet '10.50.2.0/24' on network 'MapiDagNetwork' is not Up.
                                                      Current state is 'Misconfigured'.

The Failover Cluster Manager was checked, but no errors were found, and the networks in question were “Up”, and in green status.

Looking further at the output of the Test-ReplicationHealth shows that the current state is “Misconfigured”, so let’s see how that replication traffic is configured. The following shows the output of Get-DatabaseAvailabilityGroupNetwork

RunspaceId         : 57e140b2-15ad-4822-9f94-3e1b0d34f491
Name               : MapiDagNetwork
Description        :
Subnets            : {{10.50.3.0/24,Up}, {10.50.2.0/24,Up}}
Interfaces         : {{DCExchange,Up,10.50.3.15}, {pcfexch005,Up,10.50.2.36}, {pcfexch006,Up,10.50.2.42}}
MapiAccessEnabled  : True
ReplicationEnabled : True
IgnoreNetwork      : False
Identity           : VarDAG2016\MapiDagNetwork
IsValid            : True
ObjectState        : New

RunspaceId         : 57e140b2-15ad-4822-9f94-3e1b0d34f491
Name               : ReplicationDagNetwork01
Description        :
Subnets            : {{192.168.100.0/24,Up}}
Interfaces         : {{pcfexch005,Up,192.168.100.218}, {pcfexch006,Up,192.168.100.217}}
MapiAccessEnabled  : False
ReplicationEnabled : True
IgnoreNetwork      : False
Identity           : VarDAG2016\ReplicationDagNetwork01
IsValid            : True
ObjectState        : New

An attempt was done to reset the network state by disabling the automatic configuration and re-enabling it with the following commands:

Set-DatabaseAvailabilityGroup VarDAG2016 -ManualDagNetworkConfiguration $true
Set-DatabaseAvailabilityGroup VarDAG2016 -ManualDagNetworkConfiguration $false

No change, and the seed attempt failed again.

An attempt to remove the Backup network (Here named “ReplicationDagNetwork01“) from the replication traffic was done with the following commands:

Set-DatabaseAvailabilityGroup VarDAG2016 -ManualDagNetworkConfiguration $true

Set-DatabaseAvailabilityGroupNetwork -Identity VarDAG2016\ReplicationDagNetwork01 -ReplicationEnabled:$false

No change was seen, and the seed attempt failed.

Looking further at the what options the command had, the “IgnoreNetwork” option was used:

Set-DatabaseAvailabilityGroup VarDAG2016 -ManualDagNetworkConfiguration $true

Set-DatabaseAvailabilityGroupNetwork -Identity VarDAG2016\ReplicationDagNetwork01 -ReplicationEnabled:$false -IgnoreNetwork:$true

Still no change, so I set back the autoconfiguration with the command:

Set-DatabaseAvailabilityGroup VarDAG2016 -ManualDagNetworkConfiguration $false

Running Get-DatabaseAvailabilityGroupNetwork | fl showed no visible change, but the Site-to-Site tunnel showed a massive uptick in usage, so I ran the Get-MailboxDatabaseCopyStatus command, and it showed all databases that were in a status of DisconnectedAndResynchronizing synchronizing! I retried the reseed process, and it worked!

I’m not sure why the Set-DatabaseAvailabilityGroupNetwork command showed no visible changes, but it’s obvious the changes did occur, that the replication was disabled over the BackupNet (192.168.100.x) and forced over the correct network.