Public/Get-TntInboxForwardingRuleReport.ps1

function Get-TntInboxForwardingRuleReport {
    <#
    .SYNOPSIS
        Reports on inbox forwarding rules targeting external addresses.
 
    .DESCRIPTION
        Retrieves all user and shared mailboxes and checks inbox rules for forwarding to external recipients.
        External addresses are determined by comparing against the tenant's accepted domains.
 
    .PARAMETER TenantId
        The Azure AD Tenant ID (GUID) to connect to.
 
    .PARAMETER ClientId
        The Application (Client) ID of the app registration created for security reporting.
 
    .PARAMETER ClientSecret
        The client secret for the app registration. Use this for automated scenarios.
 
    .PARAMETER CertificateThumbprint
        The thumbprint of the certificate to use for authentication instead of client secret.
 
    .EXAMPLE
        Get-TntInboxForwardingRuleReport -TenantId $tenantId -ClientId $clientId -ClientSecret $secret
 
        Checks all user mailboxes for external forwarding rules.
 
    .OUTPUTS
        System.Management.Automation.PSCustomObject
        Returns a structured object containing:
        - Summary: Total rules checked, external forwards found
        - ForwardingRules: Detailed per-rule information
 
    .NOTES
        Author: Tom de Leeuw
        Website: https://systom.dev
        Module: TenantReports
 
        Required Permissions:
        - Exchange Online app access
 
    .LINK
        https://systom.dev
    #>


    [CmdletBinding(DefaultParameterSetName = 'ClientSecret')]
    [OutputType([System.Management.Automation.PSCustomObject])]
    param(
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName, ParameterSetName = 'ClientSecret')]
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName, ParameterSetName = 'Certificate')]
        [Parameter(Mandatory = $false, ParameterSetName = 'Interactive')]
        [ValidateNotNullOrEmpty()]
        [Alias('Tenant')]
        [string]$TenantId,

        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName, ParameterSetName = 'ClientSecret')]
        [Parameter(Mandatory = $true, ValueFromPipelineByPropertyName, ParameterSetName = 'Certificate')]
        [Parameter(Mandatory = $false, ParameterSetName = 'Interactive')]
        [ValidatePattern('^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$')]
        [Alias('ApplicationId')]
        [string]$ClientId,

        [Parameter(Mandatory = $true, ParameterSetName = 'ClientSecret', ValueFromPipelineByPropertyName)]
        [ValidateNotNullOrEmpty()]
        [Alias('Secret', 'ApplicationSecret')]
        [SecureString]$ClientSecret,

        [Parameter(Mandatory = $true, ParameterSetName = 'Certificate', ValueFromPipelineByPropertyName)]
        [ValidateNotNullOrEmpty()]
        [Alias('Thumbprint')]
        [string]$CertificateThumbprint,

        [Parameter(Mandatory = $true, ParameterSetName = 'Interactive')]
        [switch]$Interactive
    )

    begin {
        Write-Information 'STARTED : Inbox forwarding rule analysis... (This may take a while)' -InformationAction Continue
    }

    process {
        try {
            $ConnectionParams = Get-ConnectionParameters -BoundParameters $PSBoundParameters
            $ConnectionInfo   = Connect-TntGraphSession @ConnectionParams

            # Connect to Exchange Online (required - throw on failure)
            try {
                if ($PSCmdlet.ParameterSetName -eq 'ClientSecret') {
                    $TokenParams = @{
                        TenantId     = $TenantId
                        ClientId     = $ClientId
                        ClientSecret = $ClientSecret
                        Scope        = 'Exchange'
                    }
                    $ExchangeToken = Get-GraphToken @TokenParams
                    Connect-ExchangeOnline -Organization $TenantId -AccessToken $ExchangeToken.AccessToken -ShowBanner:$false -ErrorAction Stop
                } else {
                    # Certificate auth requires domain name, not GUID
                    $TenantDomain = $null
                    try {
                        $Org = Get-MgOrganization -Property VerifiedDomains | Select-Object -First 1
                        if ($Org.VerifiedDomains) {
                            $TenantDomain = ($Org.VerifiedDomains.Where({ $_.IsInitial }) | Select-Object -First 1 -ExpandProperty Name)
                            if (-not $TenantDomain) {
                                $TenantDomain = ($Org.VerifiedDomains.Where({ $_.IsDefault }) | Select-Object -First 1 -ExpandProperty Name)
                            }
                        }
                    } catch {
                        Write-Verbose "Could not resolve tenant domain: $($_.Exception.Message)"
                    }

                    if (-not $TenantDomain) {
                        $PSCmdlet.ThrowTerminatingError([System.Management.Automation.ErrorRecord]::new(
                                [System.Exception]::new('Could not resolve tenant domain name. Certificate authentication requires a domain name for Exchange Online, not a tenant GUID.'),
                                'ExchangeTenantDomainResolutionError',
                                [System.Management.Automation.ErrorCategory]::ObjectNotFound,
                                $TenantId
                            ))
                    }

                    Connect-ExchangeOnline -AppId $ClientId -CertificateThumbprint $CertificateThumbprint -Organization $TenantDomain -ShowBanner:$false -ErrorAction Stop
                }
                Write-Verbose 'Successfully connected to Exchange Online.'
            } catch {
                $PSCmdlet.ThrowTerminatingError([System.Management.Automation.ErrorRecord]::new(
                        [System.Exception]::new("Exchange Online connection required: $($_.Exception.Message)"),
                        'ExchangeConnectionError',
                        [System.Management.Automation.ErrorCategory]::ConnectionError,
                        $null
                    ))
            }

            $ForwardingRules = [System.Collections.Generic.List[PSCustomObject]]::new()

            # Compiled regex for SMTP address extraction
            [regex]$SmtpRegex = [regex]::new('[Ss][Mm][Tt][Pp]:([^\]]+)', 'Compiled')

            # Get accepted domains to determine internal vs external
            Write-Verbose 'Retrieving accepted domains...'
            $AcceptedDomains = (Get-AcceptedDomain).DomainName

            # Get all user and shared mailboxes
            Write-Verbose 'Retrieving mailboxes...'
            $Mailboxes = (Get-EXOMailbox -ResultSize Unlimited -Properties UserPrincipalName, DisplayName, RecipientTypeDetails).Where(
                { $_.RecipientTypeDetails -in @('UserMailbox', 'SharedMailbox') }
            )

            Write-Verbose "Checking inbox rules for $($Mailboxes.Count) mailboxes..."
            $TotalRulesChecked = 0

            foreach ($Mbx in $Mailboxes) {
                try {
                    $Rules = Get-InboxRule -Mailbox $Mbx.UserPrincipalName -ErrorAction SilentlyContinue -WarningAction SilentlyContinue
                    if (-not $Rules) { continue }

                    foreach ($Rule in $Rules) {
                        $TotalRulesChecked++

                        # Collect all forward targets
                        [System.Collections.Generic.List[string]]$ForwardTargets = @()
                        if ($Rule.ForwardTo) { $ForwardTargets.AddRange([string[]]$Rule.ForwardTo) }
                        if ($Rule.ForwardAsAttachmentTo) { $ForwardTargets.AddRange([string[]]$Rule.ForwardAsAttachmentTo) }
                        if ($Rule.RedirectTo) { $ForwardTargets.AddRange([string[]]$Rule.RedirectTo) }

                        if ($ForwardTargets.Count -eq 0) { continue }

                        # Check each target for external domains
                        foreach ($Target in $ForwardTargets) {
                            # Extract email address - targets can be in format "DisplayName [SMTP:user@domain.com]"
                            $EmailMatch = $SmtpRegex.Match($Target)
                            $Email = if ($EmailMatch.Success) { $EmailMatch.Groups[1].Value } else { $Target }

                            $Domain = ($Email -split '@')[-1]
                            $IsExternal = $Domain -and ($Domain -notin $AcceptedDomains)

                            if ($IsExternal) {
                                $ForwardType = if ($Rule.ForwardTo -contains $Target) { 'ForwardTo' }
                                elseif ($Rule.ForwardAsAttachmentTo -contains $Target) { 'ForwardAsAttachment' }
                                else { 'RedirectTo' }

                                $ForwardingRules.Add([PSCustomObject]@{
                                        MailboxUPN      = $Mbx.UserPrincipalName
                                        MailboxDisplay  = $Mbx.DisplayName
                                        RuleName        = $Rule.Name
                                        RuleEnabled     = $Rule.Enabled
                                        ForwardType     = $ForwardType
                                        ForwardTarget   = $Email
                                        TargetDomain    = $Domain
                                        RulePriority    = $Rule.Priority
                                        RuleDescription = $Rule.Description
                                    })
                            }
                        }
                    }
                } catch {
                    Write-Warning "Failed to retrieve inbox rules for $($Mbx.UserPrincipalName): $($_.Exception.Message)"
                }
            }

            $EnabledForwards       = @($ForwardingRules.Where({ $_.RuleEnabled -eq $true }))
            $UniqueMailboxes       = ($ForwardingRules.MailboxUPN | Select-Object -Unique).Count
            $UniqueExternalDomains = ($ForwardingRules.TargetDomain | Select-Object -Unique)

            $Summary = [PSCustomObject]@{
                TenantId                = $TenantId
                ReportGeneratedDate     = [DateTime]::Now
                TotalMailboxesChecked   = $Mailboxes.Count
                TotalRulesChecked       = $TotalRulesChecked
                ExternalForwardsFound   = $ForwardingRules.Count
                EnabledExternalForwards = $EnabledForwards.Count
                MailboxesWithForwards   = $UniqueMailboxes
                ExternalDomains         = $UniqueExternalDomains
            }

            Write-Information "FINISHED : Inbox forwarding rule analysis - checked $($Mailboxes.Count) mailboxes and $($TotalRulesChecked) rules, found $($ForwardingRules.Count) external forwarding rules." -InformationAction Continue

            [PSCustomObject][Ordered]@{
                Summary         = $Summary
                ForwardingRules = $ForwardingRules.ToArray()
            }
        } catch {
            $errorRecord = [System.Management.Automation.ErrorRecord]::new(
                [System.Exception]::new("Get-TntInboxForwardingRuleReport failed: $($_.Exception.Message)", $_.Exception),
                'GetTntInboxForwardingRuleReportError',
                [System.Management.Automation.ErrorCategory]::OperationStopped,
                $TenantId
            )
            $PSCmdlet.ThrowTerminatingError($errorRecord)
        } finally {
            try {
                if ($ConnectionInfo.ShouldDisconnect) {
                    Disconnect-TntGraphSession -ConnectionState $ConnectionInfo
                }
            } catch {
                Write-Verbose "Could not disconnect from services: $($_.Exception.Message)"
            }
        }
    }
}