Src/Private/Get-AbrExoMailboxGovernance.ps1

function Get-AbrExoMailboxGovernance {
    <#
    .SYNOPSIS
    Documents Exchange Online mailbox governance: quotas, inactive mailboxes,
    deleted item retention policy, and shared mailbox lifecycle.
    .NOTES
        Version: 0.1.0
        Author: Pai Wei Sing
    #>

    [CmdletBinding()]
    param (
        [Parameter(Position = 0, Mandatory)]
        [string]$TenantId
    )

    begin {
        Write-PScriboMessage -Message "Collecting Exchange Online Mailbox Governance data for $TenantId."
        Show-AbrDebugExecutionTime -Start -TitleMessage 'MailboxGovernance'
    }

    process {
        Section -Style Heading2 'Governance' {
            Paragraph "The following section documents mailbox quotas, deleted item retention, inactive mailboxes, and shared mailbox governance for tenant $TenantId."
            BlankLine

            #region Helper: safely convert EXO quota value to GB string
            # EXO REST returns quotas as ByteQuantifiedSize objects OR strings like "50 GB (53,687,091,200 bytes)" or "Unlimited"
            # .Value property may be null; bytes can be extracted from the string representation
            function ConvertTo-QuotaGB {
                param([object]$Quota)
                if ($null -eq $Quota) { return 'Unlimited' }
                $str = "$Quota"
                if ($str -eq 'Unlimited' -or $str -eq '') { return 'Unlimited' }
                # Try native ByteQuantifiedSize object
                try {
                    if ($Quota.PSObject.Methods.Name -contains 'ToBytes') {
                        $bytes = $Quota.ToBytes()
                        return [math]::Round($bytes / 1GB, 1)
                    }
                } catch {}
                # Try .Value sub-property
                try {
                    if ($Quota.Value -ne $null) {
                        $bytes = $Quota.Value.ToBytes()
                        return [math]::Round($bytes / 1GB, 1)
                    }
                } catch {}
                # Parse from string format: "50 GB (53,687,091,200 bytes)"
                if ($str -match '\(([0-9,]+)\s*bytes\)') {
                    $bytes = [long]($Matches[1] -replace ',','')
                    return [math]::Round($bytes / 1GB, 1)
                }
                # Parse simple "50 GB" or "50.5 GB"
                if ($str -match '^([\d.]+)\s*GB') { return [double]$Matches[1] }
                if ($str -match '^([\d.]+)\s*MB') { return [math]::Round([double]$Matches[1] / 1024, 2) }
                # Fallback: return the raw string
                return $str
            }

            function ConvertTo-QuotaMB {
                param([object]$Quota)
                if ($null -eq $Quota) { return 'Default' }
                $str = "$Quota"
                if ($str -eq 'Unlimited' -or $str -eq '') { return 'Unlimited' }
                try {
                    if ($Quota.PSObject.Methods.Name -contains 'ToBytes') {
                        return [math]::Round($Quota.ToBytes() / 1MB, 0)
                    }
                } catch {}
                try {
                    if ($Quota.Value -ne $null) {
                        return [math]::Round($Quota.Value.ToBytes() / 1MB, 0)
                    }
                } catch {}
                if ($str -match '\(([0-9,]+)\s*bytes\)') {
                    return [math]::Round(([long]($Matches[1] -replace ',','')) / 1MB, 0)
                }
                if ($str -match '^([\d.]+)\s*MB') { return [double]$Matches[1] }
                if ($str -match '^([\d.]+)\s*GB') { return [math]::Round([double]$Matches[1] * 1024, 0) }
                return $str
            }
            #endregion

            #region Mailbox Quota Summary
            try {
                Write-Host " - Retrieving mailbox quota configuration..."
                $AllMailboxes = Get-Mailbox -ResultSize Unlimited -ErrorAction Stop |
                    Where-Object { $_.RecipientTypeDetails -in @('UserMailbox','SharedMailbox') }

                # In Exchange Online (cloud-only), UseDatabaseQuotaDefaults is ALWAYS $false
                # because there is no database — quotas are set per licence plan.
                # Instead, detect genuine custom overrides by comparing mailbox quota values
                # against the known plan defaults.
                $Plans = Get-MailboxPlan -ErrorAction SilentlyContinue | Sort-Object DisplayName
                $PlanProhibitSendValues = @{}
                if ($Plans) {
                    foreach ($Plan in $Plans) {
                        $planGB = ConvertTo-QuotaGB $Plan.ProhibitSendQuota
                        if ($planGB -ne 'Unlimited' -and $null -ne $planGB) {
                            $PlanProhibitSendValues["$planGB"] = $true
                        }
                    }
                }

                # A mailbox has a custom quota if its ProhibitSendQuota differs from ALL plan defaults
                $CustomQuota = @($AllMailboxes | Where-Object {
                    $mbxGB = ConvertTo-QuotaGB $_.ProhibitSendQuota
                    $mbxGB -ne 'Unlimited' -and $null -ne $mbxGB -and -not $PlanProhibitSendValues.ContainsKey("$mbxGB")
                })
                $DefaultQuota = @($AllMailboxes | Where-Object {
                    $mbxGB = ConvertTo-QuotaGB $_.ProhibitSendQuota
                    $mbxGB -eq 'Unlimited' -or $null -eq $mbxGB -or $PlanProhibitSendValues.ContainsKey("$mbxGB")
                })

                Section -Style Heading3 'Mailbox Quota Overview' {
                    Paragraph "Exchange Online enforces storage quotas on all mailboxes based on the assigned licence plan. $($DefaultQuota.Count) mailbox(es) use their plan default quota. $($CustomQuota.Count) mailbox(es) have non-standard quota values."
                    BlankLine

                    # Plan defaults from Get-MailboxPlan
                    if ($Plans) {
                        $PlanObj = [System.Collections.ArrayList]::new()
                        foreach ($Plan in $Plans) {
                            $planInObj = [ordered] @{
                                'Plan Name'                     = $Plan.DisplayName
                                'Issue Warning (GB)'            = ConvertTo-QuotaGB $Plan.IssueWarningQuota
                                'Prohibit Send (GB)'            = ConvertTo-QuotaGB $Plan.ProhibitSendQuota
                                'Prohibit Send/Receive (GB)'    = ConvertTo-QuotaGB $Plan.ProhibitSendReceiveQuota
                                'Max Receive Size (MB)'         = ConvertTo-QuotaMB $Plan.MaxReceiveSize
                                'Recoverable Items (GB)'        = ConvertTo-QuotaGB $Plan.RecoverableItemsQuota
                            }
                            $PlanObj.Add([pscustomobject]$planInObj) | Out-Null
                        }
                        $PlanTableParams = @{ Name = "Mailbox Plan Quotas - $TenantId"; List = $false; ColumnWidths = 22, 12, 13, 18, 17, 18 }
                        if ($Report.ShowTableCaptions) { $PlanTableParams['Caption'] = "- $($PlanTableParams.Name)" }
                        if ($PlanObj.Count -gt 0) { $PlanObj | Table @PlanTableParams }
                        $script:ExcelSheets['Mailbox Plan Quotas'] = $PlanObj
                    }

                    # Custom quota exceptions (InfoLevel 2) — only show if genuinely non-standard
                    if ($CustomQuota.Count -gt 0 -and $InfoLevel.MailboxGovernance -ge 2) {
                        BlankLine
                        Paragraph "The following $($CustomQuota.Count) mailbox(es) have quota values outside the standard plan defaults:"
                        BlankLine
                        $CqObj = [System.Collections.ArrayList]::new()
                        foreach ($Mbx in ($CustomQuota | Sort-Object DisplayName)) {
                            $cqInObj = [ordered] @{
                                'Display Name'           = $Mbx.DisplayName
                                'Type'                   = $Mbx.RecipientTypeDetails
                                'Issue Warning (GB)'     = ConvertTo-QuotaGB $Mbx.IssueWarningQuota
                                'Prohibit Send (GB)'     = ConvertTo-QuotaGB $Mbx.ProhibitSendQuota
                                'Prohibit Send/Rcv (GB)' = ConvertTo-QuotaGB $Mbx.ProhibitSendReceiveQuota
                            }
                            $CqObj.Add([pscustomobject]$cqInObj) | Out-Null
                        }
                        $CqTableParams = @{ Name = "Custom Quota Mailboxes - $TenantId"; List = $false; ColumnWidths = 28, 18, 18, 18, 18 }
                        if ($Report.ShowTableCaptions) { $CqTableParams['Caption'] = "- $($CqTableParams.Name)" }
                        if ($CqObj.Count -gt 0) { $CqObj | Table @CqTableParams }
                        $script:ExcelSheets['Custom Quota Mailboxes'] = $CqObj
                    }
                }
            } catch {
                Write-ExoError 'MailboxGovernance' "Unable to retrieve mailbox quota data: $($_.Exception.Message)"
                Paragraph "Unable to retrieve mailbox quota data: $($_.Exception.Message)"
            }
            #endregion

            #region Deleted Item Retention & Recovery
            try {
                Write-Host " - Retrieving deleted item retention settings..."
                $AllMailboxes = Get-Mailbox -ResultSize Unlimited -ErrorAction Stop |
                    Where-Object { $_.RecipientTypeDetails -in @('UserMailbox','SharedMailbox') }

                $SirEnabled  = @($AllMailboxes | Where-Object { $_.SingleItemRecoveryEnabled -eq $true }).Count
                $SirDisabled = @($AllMailboxes | Where-Object { $_.SingleItemRecoveryEnabled -ne $true }).Count

                # Fix #17: Convert RetainDeletedItemsFor TimeSpan/string to readable "N days" format
                $RetGroups = $AllMailboxes | ForEach-Object {
                    $raw = "$($_.RetainDeletedItemsFor)"
                    $days = if ($raw -match '^(\d+)\.') { "$($Matches[1]) days" }
                            elseif ($raw -match '^(\d+)$') { "$($Matches[1]) days" }
                            else { $raw }
                    [pscustomobject]@{ DisplayDays = $days; Mailbox = $_.DisplayName }
                } | Group-Object -Property DisplayDays | Sort-Object Name

                Section -Style Heading3 'Deleted Item Retention & Single Item Recovery' {
                    Paragraph "Deleted item retention controls how long soft-deleted items are recoverable. Single Item Recovery (SIR) prevents purging of the Recoverable Items folder."
                    BlankLine

                    $RecSumObj = [System.Collections.ArrayList]::new()
                    $recSumInObj = [ordered] @{
                        'Total Mailboxes Checked'          = @($AllMailboxes).Count
                        'Single Item Recovery Enabled'     = "$SirEnabled of $(@($AllMailboxes).Count)"
                        'Single Item Recovery Disabled'    = $SirDisabled
                        'SIR Coverage (%)'                 = "$([math]::Round(($SirEnabled / [math]::Max(@($AllMailboxes).Count,1)) * 100, 0))%"
                        'Recommendation'                   = if ($SirDisabled -gt 0) { "REVIEW: Enable SIR on all mailboxes." } else { "OK: SIR enabled on all mailboxes." }
                    }
                    $RecSumObj.Add([pscustomobject]$recSumInObj) | Out-Null

                    $null = (& {
                        if ($HealthCheck.ExchangeOnline.Mailboxes) {
                            $null = ($RecSumObj | Where-Object { $_.SirDisabled -gt 0 } | Set-Style -Style Warning | Out-Null)
                        }
                    })

                    $RecSumTableParams = @{ Name = "SIR Summary - $TenantId"; List = $true; ColumnWidths = 45, 55 }
                    if ($Report.ShowTableCaptions) { $RecSumTableParams['Caption'] = "- $($RecSumTableParams.Name)" }
                    $RecSumObj | Table @RecSumTableParams

                    BlankLine
                    Paragraph "Deleted item retention distribution across mailboxes:"
                    BlankLine

                    $RitObj = [System.Collections.ArrayList]::new()
                    foreach ($Grp in $RetGroups) {
                        $RitObj.Add([pscustomobject]@{
                            'Retain Deleted Items For'  = $Grp.Name   # Now "14 days" not "14.00:00:00"
                            'Mailbox Count'             = $Grp.Count
                        }) | Out-Null
                    }
                    $RitTableParams = @{ Name = "Deleted Item Retention Distribution - $TenantId"; List = $false; ColumnWidths = 60, 40 }
                    if ($Report.ShowTableCaptions) { $RitTableParams['Caption'] = "- $($RitTableParams.Name)" }
                    if ($RitObj.Count -gt 0) { $RitObj | Table @RitTableParams }

                    # Mailboxes without SIR (InfoLevel 2)
                    if ($InfoLevel.MailboxGovernance -ge 2 -and $SirDisabled -gt 0) {
                        BlankLine
                        $NoSirMailboxes = @($AllMailboxes | Where-Object { $_.SingleItemRecoveryEnabled -ne $true } | Sort-Object DisplayName)
                        $NoSirObj = [System.Collections.ArrayList]::new()
                        foreach ($Mbx in $NoSirMailboxes) {
                            $NoSirObj.Add([pscustomobject](ConvertTo-HashToYN ([ordered]@{
                                'Display Name'    = $Mbx.DisplayName
                                'UPN'             = $Mbx.UserPrincipalName
                                'Type'            = $Mbx.RecipientTypeDetails
                                'Litigation Hold' = $Mbx.LitigationHoldEnabled
                            }))) | Out-Null
                        }
                        $null = (& {
                            if ($HealthCheck.ExchangeOnline.Mailboxes) {
                                $null = ($NoSirObj | Set-Style -Style Warning | Out-Null)
                            }
                        })
                        Paragraph "The following $($NoSirMailboxes.Count) mailbox(es) have Single Item Recovery disabled. Remediate: Set-Mailbox -Identity <UPN> -SingleItemRecoveryEnabled `$true"
                        BlankLine
                        $NoSirTableParams = @{ Name = "Mailboxes Without SIR - $TenantId"; List = $false; ColumnWidths = 25, 35, 20, 20 }
                        if ($Report.ShowTableCaptions) { $NoSirTableParams['Caption'] = "- $($NoSirTableParams.Name)" }
                        if ($NoSirObj.Count -gt 0) { $NoSirObj | Table @NoSirTableParams }
                        $script:ExcelSheets['Mailboxes Without SIR'] = $NoSirObj
                    }
                }
            } catch {
                Write-ExoError 'MailboxGovernance' "Unable to retrieve deleted item retention data: $($_.Exception.Message)"
            }
            #endregion

            #region Inactive Mailboxes
            try {
                Write-Host " - Retrieving inactive mailboxes..."
                $InactiveMailboxes = Get-Mailbox -InactiveMailboxOnly -ResultSize Unlimited -ErrorAction Stop | Sort-Object DisplayName

                Section -Style Heading3 'Inactive Mailboxes' {
                    if ($InactiveMailboxes -and @($InactiveMailboxes).Count -gt 0) {
                        Paragraph "The following $(@($InactiveMailboxes).Count) inactive mailbox(es) exist in tenant $TenantId. Inactive mailboxes are soft-deleted mailboxes preserved by litigation hold or retention policy. They consume storage and should be reviewed regularly."
                        BlankLine

                        $InactObj = [System.Collections.ArrayList]::new()
                        foreach ($Mbx in $InactiveMailboxes) {
                            $InactObj.Add([pscustomobject](ConvertTo-HashToYN ([ordered]@{
                                'Display Name'      = $Mbx.DisplayName
                                'UPN'               = $Mbx.UserPrincipalName
                                'Soft Deleted'      = if ($Mbx.WhenSoftDeleted) { $Mbx.WhenSoftDeleted.ToString('yyyy-MM-dd') } else { 'Unknown' }
                                'Litigation Hold'   = $Mbx.LitigationHoldEnabled
                                'Hold Duration'     = if ($Mbx.LitigationHoldDuration) { "$($Mbx.LitigationHoldDuration)" } else { 'Indefinite' }
                                'In-Place Hold'     = if ($Mbx.InPlaceHolds -and $Mbx.InPlaceHolds.Count -gt 0) { 'Yes' } else { 'No' }
                                'Archive Status'    = $Mbx.ArchiveStatus
                            }))) | Out-Null
                        }

                        $InactTableParams = @{ Name = "Inactive Mailboxes - $TenantId"; List = $false; ColumnWidths = 16, 24, 12, 10, 12, 10, 16 }
                        if ($Report.ShowTableCaptions) { $InactTableParams['Caption'] = "- $($InactTableParams.Name)" }
                        if ($InactObj.Count -gt 0) { $InactObj | Table @InactTableParams }
                        $script:ExcelSheets['Inactive Mailboxes'] = $InactObj
                    } else {
                        Paragraph "No inactive mailboxes found in tenant $TenantId."
                    }
                }
            } catch {
                Write-ExoError 'MailboxGovernance' "Unable to retrieve inactive mailboxes: $($_.Exception.Message)"
                Paragraph "Unable to retrieve inactive mailbox data: $($_.Exception.Message)"
            }
            #endregion

            #region Shared Mailbox Governance
            try {
                Write-Host " - Retrieving shared mailbox governance..."
                $SharedMailboxes = Get-Mailbox -RecipientTypeDetails SharedMailbox -ResultSize Unlimited -ErrorAction Stop | Sort-Object DisplayName

                Section -Style Heading3 'Shared Mailbox Governance' {
                    if ($SharedMailboxes -and @($SharedMailboxes).Count -gt 0) {
                        Paragraph "The following $(@($SharedMailboxes).Count) shared mailbox(es) are documented for governance purposes. Shared mailboxes should have designated owners and be reviewed periodically."
                        BlankLine

                        $SmGovObj = [System.Collections.ArrayList]::new()
                        foreach ($Mbx in $SharedMailboxes) {
                            $Owners = 'None Assigned'
                            try {
                                $OwnerPerms = Get-MailboxPermission -Identity $Mbx.Identity -ErrorAction SilentlyContinue |
                                    Where-Object {
                                        $_.AccessRights -contains 'FullAccess' -and
                                        -not $_.IsInherited -and
                                        $_.User -notlike 'NT AUTHORITY\*' -and
                                        $_.User -notlike 'S-1-5-*'
                                    }
                                if ($OwnerPerms) {
                                    $OwnerList = @($OwnerPerms.User)
                                    $Owners = ($OwnerList | Select-Object -First 3) -join ', '
                                    if ($OwnerList.Count -gt 3) { $Owners += " (+$($OwnerList.Count - 3) more)" }
                                }
                            } catch {}

                            $LastLogon    = 'Unknown'
                            $StorageUsed  = '--'
                            try {
                                $Stats = Get-MailboxStatistics -Identity $Mbx.Identity -ErrorAction SilentlyContinue
                                if ($Stats) {
                                    if ($Stats.LastLogonTime) {
                                        $DaysSince = [int](New-TimeSpan -Start $Stats.LastLogonTime -End (Get-Date)).TotalDays
                                        $LastLogon = "$($Stats.LastLogonTime.ToString('yyyy-MM-dd')) ($DaysSince days ago)"
                                    }
                                    if ($Stats.TotalItemSize) {
                                        $StorageUsed = "$($Stats.TotalItemSize)"
                                        # Try to get a cleaner representation
                                        try {
                                            $bytes = $null
                                            if ("$($Stats.TotalItemSize)" -match '\(([0-9,]+)\s*bytes\)') {
                                                $bytes = [long]($Matches[1] -replace ',','')
                                                $StorageUsed = "$([ math]::Round($bytes / 1MB, 0)) MB"
                                            }
                                        } catch {}
                                    }
                                }
                            } catch {}

                            $SmGovObj.Add([pscustomobject](ConvertTo-HashToYN ([ordered]@{
                                'Shared Mailbox'        = $Mbx.DisplayName
                                'Primary SMTP'          = $Mbx.PrimarySmtpAddress
                                'Full Access Owners'    = $Owners
                                'Audit Enabled'         = $Mbx.AuditEnabled
                                'Litigation Hold'       = $Mbx.LitigationHoldEnabled
                                'Storage Used'          = $StorageUsed
                                'Last Logon'            = $LastLogon
                            }))) | Out-Null
                        }

                        $null = (& {
                            if ($HealthCheck.ExchangeOnline.Mailboxes) {
                                $null = ($SmGovObj | Where-Object { $_.'Full Access Owners' -eq 'None Assigned' } | Set-Style -Style Warning | Out-Null)
                                $null = ($SmGovObj | Where-Object { $_.'Audit Enabled' -eq 'No' } | Set-Style -Style Warning | Out-Null)
                            }
                        })

                        $SmGovTableParams = @{ Name = "Shared Mailbox Governance - $TenantId"; List = $false; ColumnWidths = 14, 18, 22, 8, 10, 12, 16 }
                        if ($Report.ShowTableCaptions) { $SmGovTableParams['Caption'] = "- $($SmGovTableParams.Name)" }
                        if ($SmGovObj.Count -gt 0) { $SmGovObj | Table @SmGovTableParams }
                        $script:ExcelSheets['Shared Mailbox Governance'] = $SmGovObj
                    } else {
                        Paragraph "No shared mailboxes found in tenant $TenantId."
                    }
                }
            } catch {
                Write-ExoError 'MailboxGovernance' "Unable to retrieve shared mailbox governance data: $($_.Exception.Message)"
            }
            #endregion
        }
    }

    end {
        Show-AbrDebugExecutionTime -End -TitleMessage 'MailboxGovernance'
    }
}