Public/Get-MailboxForwardingAudit.ps1

function Get-MailboxForwardingAudit {
    <#
    .SYNOPSIS
        Audits Exchange Online mailboxes for forwarding to external recipients.

    .DESCRIPTION
        Checks every user/shared mailbox for:
          1. Mailbox-level forwarding (ForwardingSmtpAddress / ForwardingAddress).
          2. Inbox rules that forward, redirect or forward-as-attachment to external addresses.
        An address is external when its domain is not in -InternalDomains (or, if not
        supplied, not an accepted domain of the tenant). Results are exported to CSV.

    .PARAMETER InternalDomains
        Domains to treat as internal. If omitted, the tenant's accepted domains are used.

    .PARAMETER OutputPath
        Full path of the CSV file. Defaults to .\MailboxForwardingAudit_<date>.csv

    .PARAMETER SkipInboxRules
        Only check mailbox-level forwarding (much faster on large tenants).

    .EXAMPLE
        Get-MailboxForwardingAudit

    .EXAMPLE
        Get-MailboxForwardingAudit -InternalDomains contoso.com -SkipInboxRules

    .NOTES
        Author : Anthony Buhnerkemper
        Requires: ExchangeOnlineManagement (v3+)
        Role : View-Only Recipients + View-Only Configuration (e.g. Global Reader)
        Inbox rule enumeration is slow; expect several seconds per mailbox.
    #>

    [CmdletBinding()]
    param(
        [Parameter()]
        [string[]]$InternalDomains,

        [Parameter()]
        [string]$OutputPath = (Join-Path (Get-Location).Path ("MailboxForwardingAudit_{0}.csv" -f (Get-Date -Format 'yyyyMMdd'))),

        [Parameter()]
        [switch]$SkipInboxRules
    )

    # Runtime dependency (not enforced at import): ExchangeOnlineManagement

    $ErrorActionPreference = 'Stop'

    # Exchange Online uses RBAC roles rather than Graph scopes; a read-only role is sufficient.
    Connect-ExchangeOnline -ShowBanner:$false

    if (-not $InternalDomains) {
        $InternalDomains = (Get-AcceptedDomain).DomainName
    }
    $InternalDomains = $InternalDomains | ForEach-Object { $_.ToLower() }

    function Test-IsExternal {
        <# Returns $true if the address' domain is not an internal domain. #>
        param([string]$Address)
        if ([string]::IsNullOrWhiteSpace($Address)) { return $false }
        # Rule recipients look like: "Name" [SMTP:user@domain.com]; strip to the address
        $clean = ($Address -replace '^.*\[(SMTP|smtp):', '' -replace '\]$', '' -replace '^smtp:', '').Trim()
        if ($clean -notmatch '@') { return $false }  # internal object (no SMTP domain)
        $domain = $clean.Split('@')[-1].ToLower()
        return ($InternalDomains -notcontains $domain)
    }

    $mailboxes = Get-EXOMailbox -ResultSize Unlimited -RecipientTypeDetails UserMailbox, SharedMailbox `
        -Properties ForwardingSmtpAddress, ForwardingAddress, DeliverToMailboxAndForward

    $results = [System.Collections.Generic.List[object]]::new()
    $i = 0
    foreach ($mbx in $mailboxes) {
        $i++
        Write-Progress -Activity 'Auditing mailboxes' -Status $mbx.UserPrincipalName -PercentComplete (($i / $mailboxes.Count) * 100)

        # 1. Mailbox-level SMTP forwarding
        if ($mbx.ForwardingSmtpAddress -and (Test-IsExternal $mbx.ForwardingSmtpAddress)) {
            $results.Add([pscustomobject]@{
                Mailbox      = $mbx.UserPrincipalName
                Type         = 'MailboxForwarding'
                RuleName     = ''
                Enabled      = $true
                ForwardTo    = $mbx.ForwardingSmtpAddress
                KeepCopy     = $mbx.DeliverToMailboxAndForward
            })
        }
        # ForwardingAddress points to a recipient object (often a mail contact)
        if ($mbx.ForwardingAddress) {
            $target = Get-Recipient -Identity $mbx.ForwardingAddress -ErrorAction SilentlyContinue
            if ($target -and (Test-IsExternal $target.PrimarySmtpAddress)) {
                $results.Add([pscustomobject]@{
                    Mailbox   = $mbx.UserPrincipalName
                    Type      = 'MailboxForwardingContact'
                    RuleName  = ''
                    Enabled   = $true
                    ForwardTo = $target.PrimarySmtpAddress
                    KeepCopy  = $mbx.DeliverToMailboxAndForward
                })
            }
        }

        # 2. Inbox rules
        if (-not $SkipInboxRules) {
            $rules = Get-InboxRule -Mailbox $mbx.UserPrincipalName -ErrorAction SilentlyContinue
            foreach ($rule in $rules) {
                $targets = @($rule.ForwardTo) + @($rule.ForwardAsAttachmentTo) + @($rule.RedirectTo) | Where-Object { $_ }
                $external = $targets | Where-Object { Test-IsExternal $_ }
                if ($external) {
                    $results.Add([pscustomobject]@{
                        Mailbox   = $mbx.UserPrincipalName
                        Type      = 'InboxRule'
                        RuleName  = $rule.Name
                        Enabled   = $rule.Enabled
                        ForwardTo = ($external -join '; ')
                        KeepCopy  = -not $rule.DeleteMessage
                    })
                }
            }
        }
    }
    Write-Progress -Activity 'Auditing mailboxes' -Completed

    $results | Export-Csv -Path $OutputPath -NoTypeInformation
    Write-Host "Checked $($mailboxes.Count) mailboxes; $($results.Count) external forwarding finding(s). Report: $OutputPath"

    Disconnect-ExchangeOnline -Confirm:$false
}