Troubleshooting email archiving with Exchange Online and IPPS

Email archiving in Microsoft 365 is two worlds: classic MRM (retention policy tags on folders) and Purview retention (compliance holds / labels). This script reads both. Transcript first, then one command at a time — otherwise ISE drops output.

Connect

Module 3.7.0 and newer from ISE: handler bug. Use PowerShell 7. EXO role and Compliance role (or two accounts).

PowerShell
# PowerShell 7 (pwsh). ExchangeOnlineManagement 3.7.0+ from ISE hits a handler bug — do not use ISE.
# winget install --id Microsoft.PowerShell --source winget

$Mailbox = "[email protected]"   # mailbox you are diagnosing
$Admin   = "[email protected]"  # Exchange admin + Compliance admin

Connect-ExchangeOnline -UserPrincipalName $Admin
Connect-IPPSSession     -UserPrincipalName $Admin

Diagnostics

PowerShell
Start-Transcript -Path "$env:USERPROFILE\Desktop\mailbox-diag.txt"
$FormatEnumerationLimit = -1

# Run one command at a time so the transcript captures each block.

Get-Mailbox $Mailbox | Format-List
# Watch: ArchiveStatus, ArchiveGuid, AutoExpandingArchiveEnabled,
# RetentionHoldEnabled, LitigationHoldEnabled, DelayHoldApplied,
# RetentionPolicy, InPlaceHolds, RecipientTypeDetails

Get-Mailbox -Identity $Mailbox | Get-MailboxStatistics |
  Select-Object RetentionHoldEnabled, LastLogonTime, TotalItemSize, TotalDeletedItemSize

# Classic MRM (retention policy tags on folders) — not Purview labels
Export-MailboxDiagnosticLogs $Mailbox -ComponentName MRM

$logProps = Export-MailboxDiagnosticLogs $Mailbox -ExtendedProperties
$xmlprops = [xml]($logProps.MailboxLog)
$xmlprops.Properties.MailboxTable.Property | Where-Object { $_.Name -like "ELC*" }
# ELCLastSuccess / ELCLastError / ELCLastRun*  → Managed Folder Assistant

Get-MailboxStatistics $Mailbox | Format-List

Get-MailboxFolderStatistics $Mailbox -IncludeOldestAndNewestItems -IncludeAnalysis |
  Format-List

Get-MailboxFolderStatistics -Identity $Mailbox -FolderScope All |
  Select-Object FolderPath, RetentionPolicyTag

# Purview side (IPPS session)
Get-RetentionCompliancePolicy | Format-Table Name, Enabled, DistributionStatus
Get-Mailbox $Mailbox | Select-Object -ExpandProperty InPlaceHolds

Stop-Transcript

How to read it

  • ArchiveStatus = Active — online archive staat aan. Leeg archive + quotafouten: assistant draait niet of een hold blokkeert.
  • RetentionHoldEnabled — MRM slaat de mailbox over. Blijft vaak aan na migratie.
  • LitigationHoldEnabled / InPlaceHolds — er wordt niets verwijderd, ook niet uit het archive. Purview-policies verschijnen hier als hold-IDs.
  • ELCLastError — Managed Folder Assistant is gefaald. Daarom blijven items in de primaire mailbox.
  • RetentionPolicyTag per folder — klassieke MRM.
  • Purview DistributionStatus niet Success — de policy zit nog niet op de mailbox (24u–7d).

Capstone / long retain: check the mailbox is in the right Purview policy (adaptive scope or explicit location), not only whether Archive is on. Archive is storage; retention is the obligation.

Report for all mailboxes: Get-MailboxArchiveReport.ps1

The script above looks at one mailbox. To see the whole tenant at once, use this script. It creates a CSV with, per mailbox (users, shared mailboxes and Microsoft 365 groups), the archive status, the retention policy with its move-to-archive rules, all holds, the size and the custom attributes. The script only reads; it changes nothing.

Download script .ps1527 lines · PowerShell 7 / 5.1

How to run it

  1. Download the script to a folder, for example C:\Scripts.
  2. Open PowerShell 7 (pwsh) or Windows PowerShell 5.1. Not the ISE.
  3. Remove the download mark and allow scripts for this window:
PowerShell
cd C:\Scripts
Unblock-File .\Get-MailboxArchiveReport.ps1
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
  1. Run the script. It first checks the ExchangeOnlineManagement module and asks whether you want to update it. Then you sign in.
PowerShell
# Alles, rapport in C:\Temp
.\Get-MailboxArchiveReport.ps1

# Met een vast beheeraccount en eigen map
.\Get-MailboxArchiveReport.ps1 -UserPrincipalName [email protected] -OutputDirectory C:\Rapporten

# Snel: zonder grootte, aantal items en laatste aanmelding
.\Get-MailboxArchiveReport.ps1 -SkipStatistics

# Alleen gebruikers- en gedeelde mailboxen, ook inactieve mailboxen
.\Get-MailboxArchiveReport.ps1 -MailboxType UserMailbox,SharedMailbox -IncludeInactiveMailboxes

Permissions: Exchange Administrator or Global Reader is enough. If the account cannot read the retention policies, the script says so and continues without those columns. On a large tenant expect a few minutes up to an hour; -SkipStatistics is much faster.

Parameters

ParameterWhat it does
-UserPrincipalNameAccount to sign in with. Empty: an existing session or a sign-in prompt.
-OutputDirectoryFolder for the CSV files. Default C:\Temp; created if it does not exist.
-MailboxTypeUserMailbox, SharedMailbox and/or GroupMailbox. Default all three.
-IncludeInactiveMailboxesAlso inactive mailboxes (deleted, but kept by a hold).
-SkipStatisticsSkips size, item count and last logon. Much faster.
-SkipModuleCheckNo module check, for example in a scheduled task.

What to look for in the CSV

You get two files: MailboxArchiving_Report_<date>.csv (one row per mailbox) and RetentionPolicies_Report_<date>.csv (the retention policies with their tags). Open them in Excel and filter on these columns:

ColumnWhat to look for
ArchiveEnabledDisabled for someone who should have an archive.
MoveToArchiveRulesEmpty: the retention policy moves nothing to the archive.
ElcProcessingDisabledTrue: the Managed Folder Assistant skips this mailbox, so nothing is moved.
RetentionHoldEnabledTrue: retention is paused (for example during leave).
ArchiveSizeGB / ArchiveQuotaGBArchive nearly full and AutoExpandingArchive is False: turn on auto-expanding archiving.
HasActiveHold / DelayHoldAppliedA hold keeps items; deleted items do not go away.
StatisticsErrorSize or count could not be read. Run the script again later.

Get-MailboxArchiveReport.ps1

Download
PowerShell
# ================================================================================================
# 365Fanatic - Mailbox Archiving & Custom Attributes Report for Exchange Online
# ------------------------------------------------------------------------------------------------
# Exports ONE CSV with, per mailbox (User, Shared and Microsoft 365 Group mailboxes):
#   - Identity info
#   - Archive details (status, state, name, quotas, auto-expanding, archive size / item count)
#   - Retention (policy, retention hold, MRM processing, move-to-archive rules of the policy)
#   - Holds (litigation, in-place, compliance tag, delay hold) + Single Item Recovery
#   - Primary mailbox size, item count, last logon / activity
#   - CustomAttribute1 - 15 and ExtensionCustomAttribute1 - 5
# A second CSV with the retention policies and their move-to-archive tags is also created.
#
# Before connecting, the script checks the ExchangeOnlineManagement module against the
# PowerShell Gallery and asks whether you want to update it (skip with -SkipModuleCheck).
# ================================================================================================

[CmdletBinding()]
param(
    # Optional. If omitted, an existing session is reused or you are prompted to sign in.
    [string]$UserPrincipalName,

    [string]$OutputDirectory = 'C:\Temp',

    [ValidateSet('UserMailbox', 'SharedMailbox', 'GroupMailbox')]
    [string[]]$MailboxType = @('UserMailbox', 'SharedMailbox', 'GroupMailbox'),

    # Also report inactive (soft-deleted, on hold) mailboxes
    [switch]$IncludeInactiveMailboxes,

    # Much faster: skips size / item count / last logon lookups
    [switch]$SkipStatistics,

    # Skip the "update the Exchange Online module?" check (e.g. for scheduled runs)
    [switch]$SkipModuleCheck
)

$ErrorActionPreference = 'Stop'

# ------------------------------------------------------------------------------------------------
# Banner
# ------------------------------------------------------------------------------------------------
function Show-Banner {
    $art = @'
 _____   __    ____   _____                           _    _
|___ /  / /_  | ___| |  ___|  __ _  _ __    __ _ | |_ (_)  ___
  |_ \ | '_ \ |___ \ | |_    / _` || '_ \  / _` || __|| | / __|
 ___) || (_) | ___) ||  _|  | (_| || | | || (_| || |_ | || (__
|____/  \___/ |____/ |_|     \__,_||_| |_| \__,_| \__||_| \___|
'@
    Write-Host $art -ForegroundColor Cyan
    Write-Host '  Mailbox Archiving & Custom Attributes Report for Exchange Online' -ForegroundColor Yellow
    Write-Host ''
}

# ------------------------------------------------------------------------------------------------
# Helper functions
# ------------------------------------------------------------------------------------------------

# Retries only on throttling / transient errors; real errors (not found, bad parameter) fail fast.
function Invoke-WithRetry {
    param([scriptblock]$ScriptBlock, [int]$MaxAttempts = 3)
    for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
        try { return & $ScriptBlock }
        catch {
            $transient = $_.Exception.Message -match 'throttl|429|too many requests|timed out|timeout|temporar|try again'
            if (-not $transient -or $attempt -eq $MaxAttempts) { throw }
            Start-Sleep -Seconds (5 * $attempt)
        }
    }
}

# Converts "1.5 GB (1,610,612,736 bytes)" (string or ByteQuantifiedSize) to bytes. $null if unlimited/empty.
function ConvertTo-Bytes {
    param($Value)
    if ($null -eq $Value) { return $null }
    $text = $Value.ToString()
    if ($text -match 'Unlimited') { return $null }
    if ($text -match '\(([\d,\.\s]+)\s*bytes\)') { return [int64]($Matches[1] -replace '[^\d]', '') }
    return $null
}

function ConvertTo-GB {
    param($Value)
    if ($null -eq $Value) { return $null }
    if ($Value.ToString() -match 'Unlimited') { return 'Unlimited' }
    $bytes = ConvertTo-Bytes $Value
    if ($null -eq $bytes) { return $null }
    return [math]::Round($bytes / 1GB, 3)
}

function Format-DateValue {
    param($Value)
    if ($null -eq $Value -or $Value -eq '') { return $null }
    try { return ([datetime]$Value).ToString('yyyy-MM-dd HH:mm:ss') } catch { return $Value.ToString() }
}

$script:TotalSteps = 7
$script:StartTime  = Get-Date

# Timestamped status line
function Write-Step {
    param([string]$Message, [string]$Color = 'Gray')
    Write-Host ("[{0}] {1}" -f (Get-Date -Format 'HH:mm:ss'), $Message) -ForegroundColor $Color
}

# Step header, e.g. "=== Step 3/7: Reading retention policies ==="
function Write-Phase {
    param([int]$Number, [string]$Title)
    Write-Host ''
    Write-Host ("[{0}] === Step {1}/{2}: {3} ===" -f (Get-Date -Format 'HH:mm:ss'), $Number, $script:TotalSteps, $Title) -ForegroundColor Cyan
}

# Mailbox statistics by ExchangeGuid. Tries Get-ExoMailboxStatistics first (REST, fast),
# then falls back to Get-MailboxStatistics (e.g. for Microsoft 365 Group mailboxes).
function Get-StatsSafe {
    param([guid]$Guid, [switch]$Archive, [string[]]$Properties)

    $archiveArgs = @{}
    if ($Archive) { $archiveArgs['Archive'] = $true }
    $errors = @()

    try { return Get-ExoMailboxStatistics -ExchangeGuid $Guid -Properties $Properties @archiveArgs -ErrorAction Stop }
    catch { $errors += $_.Exception.Message }

    try { return Get-MailboxStatistics -Identity $Guid.ToString() @archiveArgs -ErrorAction Stop }
    catch { $errors += $_.Exception.Message }

    throw ($errors -join ' || ')
}

# Checks the installed ExchangeOnlineManagement module against the PowerShell Gallery and asks to update.
function Confirm-ExoModule {
    $installed = Get-Module -ListAvailable -Name ExchangeOnlineManagement |
                 Sort-Object Version -Descending | Select-Object -First 1

    if (-not $installed) {
        Write-Host 'The ExchangeOnlineManagement module is not installed.' -ForegroundColor Yellow
        if ((Read-Host 'Install it now from the PowerShell Gallery? (Y/N)') -match '^[Yy]') {
            Install-Module -Name ExchangeOnlineManagement -Scope CurrentUser -Force -AllowClobber
            Import-Module ExchangeOnlineManagement -Force
            Write-Host 'Module installed.' -ForegroundColor Green
            return
        }
        throw 'The ExchangeOnlineManagement module is required.'
    }

    Write-Host "Installed ExchangeOnlineManagement module : v$($installed.Version)"

    try { $latest = Find-Module -Name ExchangeOnlineManagement -ErrorAction Stop }
    catch {
        Write-Warning "Could not check the PowerShell Gallery for updates: $($_.Exception.Message)"
        return
    }

    Write-Host "Latest version on PowerShell Gallery      : v$($latest.Version)"

    if ([version]$latest.Version -le $installed.Version) {
        Write-Host 'The module is up to date.' -ForegroundColor Green
        return
    }

    # Module 3.10.0+ needs PowerShell 7.6.0+ when running in PowerShell 7 (Windows PowerShell 5.1 is fine)
    if ($PSVersionTable.PSEdition -eq 'Core' -and
        $PSVersionTable.PSVersion -lt [version]'7.6.0' -and
        [version]$latest.Version -ge [version]'3.10.0') {
        Write-Warning "v$($latest.Version) requires PowerShell 7.6.0 or later (you are on $($PSVersionTable.PSVersion)). Update PowerShell first, or run this script in Windows PowerShell 5.1. Continuing with v$($installed.Version)."
        return
    }

    Write-Host "A newer version is available." -ForegroundColor Yellow
    if ((Read-Host "Update the module to v$($latest.Version) now? (Y/N)") -match '^[Yy]') {
        try {
            Update-Module -Name ExchangeOnlineManagement -Force -ErrorAction Stop
            Remove-Module ExchangeOnlineManagement -Force -ErrorAction SilentlyContinue
            Import-Module ExchangeOnlineManagement -Force -ErrorAction Stop
            Write-Host "Module updated - now using v$((Get-Module ExchangeOnlineManagement).Version)." -ForegroundColor Green
        }
        catch {
            Write-Warning "Module update failed: $($_.Exception.Message)"
            Write-Warning "If it was installed for all users, re-run PowerShell as Administrator. Continuing with v$($installed.Version)."
        }
    }
    else {
        Write-Host "Keeping v$($installed.Version)." -ForegroundColor Gray
    }
}

# ------------------------------------------------------------------------------------------------
# Start
# ------------------------------------------------------------------------------------------------
Clear-Host
Show-Banner

Write-Phase 1 'Checking the Exchange Online PowerShell module'
if (-not $SkipModuleCheck -and [Environment]::UserInteractive) {
    Write-Step 'Looking up the installed version and comparing it with the PowerShell Gallery...' 'Yellow'
    try { Confirm-ExoModule }
    catch { Write-Host $_.Exception.Message -ForegroundColor Red; exit 1 }
}
else {
    Write-Step 'Module check skipped.' 'DarkGray'
}

# ------------------------------------------------------------------------------------------------
# Connect
# ------------------------------------------------------------------------------------------------
Write-Phase 2 'Connecting to Exchange Online'
Write-Step 'Checking for an existing Exchange Online session...' 'Yellow'
$weConnected = $false
$existing = try { @(Get-ConnectionInformation | Where-Object { $_.State -eq 'Connected' }) } catch { @() }

if ($existing.Count -gt 0) {
    Write-Host "Re-using existing Exchange Online session ($($existing[0].UserPrincipalName))." -ForegroundColor Green
}
else {
    try {
        if ($UserPrincipalName) {
            Write-Host "Connecting to Exchange Online as: $UserPrincipalName" -ForegroundColor Yellow
            Connect-ExchangeOnline -UserPrincipalName $UserPrincipalName -ShowBanner:$false -ErrorAction Stop
        }
        else {
            Write-Host 'Connecting to Exchange Online (sign-in prompt)...' -ForegroundColor Yellow
            Connect-ExchangeOnline -ShowBanner:$false -ErrorAction Stop
        }
        $weConnected = $true
        Write-Host 'Connected.' -ForegroundColor Green
    }
    catch {
        Write-Host "Failed to connect to Exchange Online: $($_.Exception.Message)" -ForegroundColor Red
        exit 1
    }
}

try {
    # --------------------------------------------------------------------------------------------
    # Retention policy -> "move to archive" rules (looked up once, reused per mailbox)
    # --------------------------------------------------------------------------------------------
    Write-Phase 3 'Reading retention policies and retention tags'
    $policyRules = @{}
    $policyReport = [System.Collections.Generic.List[object]]::new()
    try {
        $tagLookup = @{}
        Write-Step 'Running Get-RetentionPolicyTag...' 'Yellow'
        $allTags = @(Get-RetentionPolicyTag)
        foreach ($tag in $allTags) { $tagLookup[$tag.Name] = $tag }
        Write-Step "Found $($allTags.Count) retention tags." 'Gray'

        Write-Step 'Running Get-RetentionPolicy...' 'Yellow'
        $allPolicies = @(Get-RetentionPolicy)
        Write-Step "Found $($allPolicies.Count) retention policies - resolving their move-to-archive rules..." 'Gray'

        foreach ($policy in $allPolicies) {
            $moveRules = [System.Collections.Generic.List[string]]::new()
            foreach ($tagName in @($policy.RetentionPolicyTagLinks)) {
                $tag = $tagLookup[[string]$tagName]
                if (-not $tag) { continue }

                $days = $null
                if ($tag.AgeLimitForRetention) {
                    try { $days = [int]([timespan]::Parse($tag.AgeLimitForRetention.ToString())).TotalDays } catch { }
                }

                $policyReport.Add([PSCustomObject]@{
                    RetentionPolicy    = $policy.Name
                    IsDefaultPolicy    = $policy.IsDefault
                    TagName            = $tag.Name
                    TagType            = $tag.Type
                    RetentionAction    = $tag.RetentionAction
                    AgeLimitDays       = $days
                    RetentionEnabled   = $tag.RetentionEnabled
                })

                if ($tag.RetentionAction -eq 'MoveToArchive' -and $tag.RetentionEnabled) {
                    $moveRules.Add("$($tag.Name) ($days days)")
                }
            }
            $policyRules[$policy.Name] = ($moveRules -join '; ')
        }
        Write-Step "Retention lookup complete ($(@($policyRules.Keys).Count) policies, $($policyReport.Count) policy/tag links)." 'Green'
    }
    catch {
        Write-Warning "Could not read retention policies/tags (missing permission?): $($_.Exception.Message)"
    }

    # --------------------------------------------------------------------------------------------
    # Mailboxes
    #   User / Shared mailboxes : Get-ExoMailbox (fast REST cmdlet, explicit property list)
    #   Microsoft 365 Groups    : Get-Mailbox -GroupMailbox (Get-ExoMailbox does not list group mailboxes)
    # --------------------------------------------------------------------------------------------
    $properties = @(
        'DisplayName', 'UserPrincipalName', 'PrimarySmtpAddress', 'RecipientTypeDetails',
        'ExternalDirectoryObjectId', 'ExchangeGuid', 'WhenCreated', 'IsInactiveMailbox',
        # Archive
        'ArchiveStatus', 'ArchiveState', 'ArchiveName', 'ArchiveGuid',
        'ArchiveQuota', 'ArchiveWarningQuota', 'AutoExpandingArchiveEnabled',
        # Retention
        'RetentionPolicy', 'ElcProcessingDisabled', 'RetentionHoldEnabled',
        'StartDateForRetentionHold', 'EndDateForRetentionHold', 'RetentionComment', 'RetentionUrl',
        # Holds / recovery
        'LitigationHoldEnabled', 'LitigationHoldDate', 'LitigationHoldOwner', 'LitigationHoldDuration',
        'InPlaceHolds', 'ComplianceTagHoldApplied', 'DelayHoldApplied',
        'SingleItemRecoveryEnabled', 'RetainDeletedItemsFor'
    ) + (1..15 | ForEach-Object { "CustomAttribute$_" }) + (1..5 | ForEach-Object { "ExtensionCustomAttribute$_" })

    Write-Phase 4 'Retrieving mailboxes'
    $mailboxes = [System.Collections.Generic.List[object]]::new()

    $exoTypes = @($MailboxType | Where-Object { $_ -ne 'GroupMailbox' })
    if ($exoTypes.Count -gt 0) {
        Write-Step "Running Get-ExoMailbox for: $($exoTypes -join ', ') (can take a few minutes on large tenants)..." 'Yellow'
        $getParams = @{
            RecipientTypeDetails = $exoTypes
            ResultSize           = 'Unlimited'
            Properties           = $properties
        }
        if ($IncludeInactiveMailboxes) { $getParams['IncludeInactiveMailbox'] = $true }

        # If this tenant/module rejects a property, drop it and retry instead of failing the whole run
        $exoResult = $null
        $retries   = 0
        while ($null -eq $exoResult -and $retries -le 5) {
            try { $exoResult = @(Get-ExoMailbox @getParams -ErrorAction Stop) }
            catch {
                if ($_.Exception.Message -match 'InvalidProperties\s*=\s*([^\r\n]+)') {
                    $bad = @($Matches[1].Split(',') | ForEach-Object { $_.Trim().TrimEnd('.') } | Where-Object { $_ })
                    Write-Warning "Skipping properties not supported here: $($bad -join ', ')"
                    $getParams['Properties'] = @($getParams['Properties'] | Where-Object { $_ -notin $bad })
                    $retries++
                }
                else { throw }
            }
        }
        if ($null -eq $exoResult) { throw 'Could not retrieve mailboxes with Get-ExoMailbox.' }
        $mailboxes.AddRange([object[]]$exoResult)
        Write-Step "Get-ExoMailbox returned $($exoResult.Count) mailboxes." 'Green'
    }

    if ($MailboxType -contains 'GroupMailbox') {
        Write-Step 'Running Get-Mailbox -GroupMailbox for Microsoft 365 Groups (can be slow)...' 'Yellow'
        try {
            $groupResult = @(Get-Mailbox -GroupMailbox -ResultSize Unlimited -ErrorAction Stop)
            if ($groupResult.Count -gt 0) { $mailboxes.AddRange([object[]]$groupResult) }
            Write-Step "Get-Mailbox -GroupMailbox returned $($groupResult.Count) group mailboxes." 'Green'
        }
        catch { Write-Warning "Could not retrieve Microsoft 365 Group mailboxes: $($_.Exception.Message)" }
    }

    $total = $mailboxes.Count
    Write-Step "Total mailboxes to process: $total" 'Green'

    Write-Phase 5 "Collecting archive, retention, hold, statistics and custom attribute data ($total mailboxes)"

    $report  = [System.Collections.Generic.List[object]]::new()
    $counter = 0

    foreach ($mailbox in $mailboxes) {
        $counter++
        Write-Progress -Activity 'Processing mailboxes' `
                       -Status "$counter of $total - $($mailbox.DisplayName)" `
                       -PercentComplete ([math]::Round(($counter / [math]::Max($total, 1)) * 100, 1))

        $guid         = [guid]$mailbox.ExchangeGuid
        $statsError   = $null
        $stats        = $null
        $archiveStats = $null
        $hasArchive   = ($mailbox.ArchiveStatus -eq 'Active')

        # Live status line for this mailbox (finished at the end of the iteration)
        Write-Host ("[{0}] [{1}/{2}] {3} ({4}) ... " -f (Get-Date -Format 'HH:mm:ss'), $counter, $total, $mailbox.DisplayName, $mailbox.RecipientTypeDetails) -NoNewline
        if ($SkipStatistics) { Write-Host 'statistics skipped; ' -NoNewline -ForegroundColor DarkGray }

        # --- Statistics (primary + archive) ---
        if (-not $SkipStatistics) {
            try {
                $stats = Invoke-WithRetry {
                    Get-StatsSafe -Guid $guid `
                        -Properties ItemCount, TotalItemSize, TotalDeletedItemSize, LastLogonTime, LastUserActionTime, LastInteractionTime
                }
            }
            catch { $statsError = "Primary: $($_.Exception.Message)"; Write-Host 'mailbox stats FAILED; ' -NoNewline -ForegroundColor Red }
            if ($stats) { Write-Host 'mailbox stats OK; ' -NoNewline -ForegroundColor Green }

            if ($hasArchive) {
                try {
                    $archiveStats = Invoke-WithRetry {
                        Get-StatsSafe -Guid $guid -Archive `
                            -Properties ItemCount, TotalItemSize, TotalDeletedItemSize
                    }
                }
                catch {
                    $statsError = (@($statsError, "Archive: $($_.Exception.Message)") | Where-Object { $_ }) -join ' | '
                    Write-Host 'archive stats FAILED; ' -NoNewline -ForegroundColor Red
                }
                if ($archiveStats) { Write-Host 'archive stats OK; ' -NoNewline -ForegroundColor Green }
            }
        }

        # --- Holds / Single Item Recovery ---
        $inPlaceHolds      = @($mailbox.InPlaceHolds | Where-Object { $_ })
        $litigationHold    = [bool]$mailbox.LitigationHoldEnabled
        $complianceTagHold = [bool]$mailbox.ComplianceTagHoldApplied
        $hasActiveHold     = $litigationHold -or ($inPlaceHolds.Count -gt 0) -or $complianceTagHold

        $sirStatus = if ($hasActiveHold) { 'Enabled (enforced by hold)' }
                     elseif ($mailbox.SingleItemRecoveryEnabled) { "Enabled (retain $($mailbox.RetainDeletedItemsFor))" }
                     else { 'Disabled' }

        # --- Build the row (ordered so the CSV columns stay in a logical order) ---
        $row = [ordered]@{
            DisplayName               = $mailbox.DisplayName
            UserPrincipalName         = $mailbox.UserPrincipalName
            PrimarySMTP               = $mailbox.PrimarySmtpAddress
            MailboxType               = $mailbox.RecipientTypeDetails
            IsInactiveMailbox         = $mailbox.IsInactiveMailbox
            CreationDate              = Format-DateValue $mailbox.WhenCreated
            ExternalDirectoryObjectId = $mailbox.ExternalDirectoryObjectId

            # Archive
            ArchiveStatus             = $mailbox.ArchiveStatus
            ArchiveEnabled            = if ($hasArchive) { 'Enabled' } else { 'Disabled' }
            ArchiveState              = $mailbox.ArchiveState
            ArchiveName               = $mailbox.ArchiveName
            ArchiveGuid               = $mailbox.ArchiveGuid
            AutoExpandingArchive      = $mailbox.AutoExpandingArchiveEnabled
            ArchiveQuotaGB            = ConvertTo-GB $mailbox.ArchiveQuota
            ArchiveWarningQuotaGB     = ConvertTo-GB $mailbox.ArchiveWarningQuota
            ArchiveItemCount          = if ($archiveStats) { $archiveStats.ItemCount } else { $null }
            ArchiveSizeGB             = if ($archiveStats) { ConvertTo-GB $archiveStats.TotalItemSize } else { $null }
            ArchiveDeletedItemsSizeGB = if ($archiveStats) { ConvertTo-GB $archiveStats.TotalDeletedItemSize } else { $null }

            # Retention
            RetentionPolicy           = $mailbox.RetentionPolicy
            MoveToArchiveRules        = if ($mailbox.RetentionPolicy) { $policyRules[[string]$mailbox.RetentionPolicy] } else { $null }
            ElcProcessingDisabled     = $mailbox.ElcProcessingDisabled
            RetentionHoldEnabled      = $mailbox.RetentionHoldEnabled
            RetentionHoldStart        = Format-DateValue $mailbox.StartDateForRetentionHold
            RetentionHoldEnd          = Format-DateValue $mailbox.EndDateForRetentionHold
            RetentionComment          = $mailbox.RetentionComment
            RetentionUrl              = $mailbox.RetentionUrl

            # Holds & Single Item Recovery
            HasActiveHold             = $hasActiveHold
            LitigationHoldEnabled     = $litigationHold
            LitigationHoldDate        = Format-DateValue $mailbox.LitigationHoldDate
            LitigationHoldOwner       = $mailbox.LitigationHoldOwner
            LitigationHoldDuration    = $mailbox.LitigationHoldDuration
            InPlaceHoldsCount         = $inPlaceHolds.Count
            InPlaceHolds              = ($inPlaceHolds -join ', ')
            ComplianceTagHoldApplied  = $complianceTagHold
            DelayHoldApplied          = $mailbox.DelayHoldApplied
            SingleItemRecoveryEnabled = $mailbox.SingleItemRecoveryEnabled
            RetainDeletedItemsFor     = $mailbox.RetainDeletedItemsFor
            SingleItemRecoveryStatus  = $sirStatus

            # Primary mailbox statistics
            ItemCount                 = if ($stats) { $stats.ItemCount } else { $null }
            TotalItemSizeGB           = if ($stats) { ConvertTo-GB $stats.TotalItemSize } else { $null }
            DeletedItemsSizeGB        = if ($stats) { ConvertTo-GB $stats.TotalDeletedItemSize } else { $null }
            LastLogonTime             = if ($stats) { Format-DateValue $stats.LastLogonTime } else { $null }
            LastUserActionTime        = if ($stats) { Format-DateValue $stats.LastUserActionTime } else { $null }
            LastInteractionTime       = if ($stats) { Format-DateValue $stats.LastInteractionTime } else { $null }
        }

        # Custom attributes 1 - 15 and extension attributes 1 - 5
        foreach ($i in 1..15) { $row["CustomAttribute$i"] = $mailbox."CustomAttribute$i" }
        foreach ($i in 1..5)  { $row["ExtensionCustomAttribute$i"] = $mailbox."ExtensionCustomAttribute$i" }

        $row['StatisticsError'] = $statsError

        $report.Add([PSCustomObject]$row)

        $sizeText = if ($stats) { " | size $(ConvertTo-GB $stats.TotalItemSize) GB" } else { '' }
        Write-Host ("done | archive: {0}{1}" -f $(if ($hasArchive) { 'yes' } else { 'no' }), $sizeText) -ForegroundColor Gray
    }

    Write-Progress -Activity 'Processing mailboxes' -Completed

    # --------------------------------------------------------------------------------------------
    # Export CSV
    # --------------------------------------------------------------------------------------------
    Write-Phase 6 'Exporting the CSV report'
    Write-Step "Collection finished: $($report.Count) mailboxes in the report." 'Green'
    if (-not (Test-Path $OutputDirectory)) {
        Write-Step "Creating output folder $OutputDirectory..." 'Yellow'
        New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null
    }
    $timestamp  = Get-Date -Format 'yyyy-MM-dd_HH-mm'
    $outputPath = Join-Path $OutputDirectory "MailboxArchiving_Report_$timestamp.csv"

    Write-Step "Writing mailbox report ($($report.Count) rows)..." 'Yellow'
    $report | Export-Csv -Path $outputPath -NoTypeInformation -Encoding UTF8
    Write-Step 'Mailbox report written.' 'Green'

    if ($policyReport.Count -gt 0) {
        $policyPath = Join-Path $OutputDirectory "RetentionPolicies_Report_$timestamp.csv"
        Write-Step "Writing retention policy report ($($policyReport.Count) rows)..." 'Yellow'
        $policyReport | Export-Csv -Path $policyPath -NoTypeInformation -Encoding UTF8
        Write-Step 'Retention policy report written.' 'Green'
    }

    Write-Host "`nMailbox report : $outputPath" -ForegroundColor Cyan
    if ($policyPath) { Write-Host "Policy report  : $policyPath" -ForegroundColor Cyan }

    # --------------------------------------------------------------------------------------------
    # Summary
    # --------------------------------------------------------------------------------------------
    Write-Phase 7 'Summary'
    Write-Host "Total mailboxes processed       : $($report.Count)"
    $report | Group-Object MailboxType | ForEach-Object { Write-Host ("  {0,-28}: {1}" -f $_.Name, $_.Count) }
    Write-Host "Archive enabled                 : $(@($report | Where-Object { $_.ArchiveEnabled -eq 'Enabled' }).Count)"
    Write-Host "Archive disabled                : $(@($report | Where-Object { $_.ArchiveEnabled -eq 'Disabled' }).Count)"
    Write-Host "Auto-expanding archive          : $(@($report | Where-Object { $_.AutoExpandingArchive -eq $true }).Count)"
    Write-Host "With active holds               : $(@($report | Where-Object { $_.HasActiveHold }).Count)"
    Write-Host "Retention hold enabled          : $(@($report | Where-Object { $_.RetentionHoldEnabled }).Count)"
    Write-Host "Single Item Recovery enabled    : $(@($report | Where-Object { $_.SingleItemRecoveryStatus -like 'Enabled*' }).Count)"
    Write-Host "Inactive mailboxes              : $(@($report | Where-Object { $_.IsInactiveMailbox }).Count)"
    $withErrors = @($report | Where-Object { $_.StatisticsError }).Count
    if ($withErrors -gt 0) { Write-Host "Mailboxes with statistics errors: $withErrors (see StatisticsError column)" -ForegroundColor Yellow }
    Write-Step ("Finished in {0}." -f ((Get-Date) - $script:StartTime).ToString('hh\:mm\:ss')) 'Green'
}
finally {
    if ($weConnected) {
        Write-Step 'Disconnecting from Exchange Online...' 'Yellow'
        Disconnect-ExchangeOnline -Confirm:$false -ErrorAction SilentlyContinue
        Write-Step 'Disconnected.' 'Gray'
    }
}

Questions about this, or need help setting it up? We are happy to think along.

Message on LinkedIn →