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' } } |