Friday, October 9, 2026

Exchange Online - Archive Emails

Archive Emails Older Than X Years in Exchange Online – The Server-Side Way (MRM + PowerShell)

Scenario: A user's mailbox is almost full. They tried Outlook rules / bulk "Move to folder" to archive emails older than 3 years, but it keeps failing after a few thousand items. Fix: Let Exchange Online do it on the server with MRM (Messaging Records Management) + Online Archive – no client, no throttling, any volume.


TL;DR

ItemValue
ProblemOutlook client-side bulk moves/rules fail on large volumes
SolutionDefault Policy Tag (DPT) with Move to Archive action + Online Archive
EngineManaged Folder Assistant (MFA) – runs server-side
ToolingExchange Online PowerShell (ExchangeOnlineManagement module)
Time to completeMinutes to configure; MFA may take one or more cycles on large mailboxes
User impactNone – old mail appears under Online Archive in Outlook / OWA with the same folder structure

Why Outlook Rules Fail

ApproachRuns whereLimitation
Outlook rule / "Run rules now"ClientStops on large item counts, depends on Outlook staying open
Drag-and-drop / Move to folderClientThrottled, times out, no resume
Outlook AutoArchive (.pst)ClientCreates local PST – not recommended, not available in new Outlook
MRM Move-to-Archive tagServer (Exchange Online)No client dependency, processes the full mailbox

What is MRM?

Messaging Records Management = Exchange feature that automatically moves or deletes mailbox items based on age.

ComponentWhat it is
Retention TagRule: after N days → action (Move to Archive / Delete)
Retention PolicyContainer of tags; one policy per mailbox
Managed Folder Assistant (MFA)Background process that stamps tags and performs the action

Retention tag types

Tag typeApplies toAllowed actions
Default Policy Tag (DPT)Whole mailbox (untagged items)Move to Archive, Delete and Allow Recovery, Permanently Delete
Retention Policy Tag (RPT)A default folder (Inbox, Sent Items, Deleted Items…)Delete only
Personal TagFolders/items the user tags in OutlookMove to Archive, Delete and Allow Recovery, Permanently Delete

⚠️ You can't create an Inbox-only "Move to Archive" tag. RPTs support delete actions only. For admin-driven archiving, use a DPT (whole mailbox). For folder-specific archiving, users can apply a Personal tag (shown as Archive Policy in Outlook).

Policy rules to remember

RuleDetail
DPTs per policyMax one Move-to-Archive DPT + one Delete DPT (+ one voicemail DPT)
Age orderingMove-to-Archive DPT age must be lower than the Delete DPT age
No archive mailboxMove-to-Archive action does nothing
MFA scheduleProcesses each mailbox at least once every 7 days; force it with Start-ManagedFolderAssistant
BlockersRetentionHoldEnabled = $true or ElcProcessingDisabled = $true stops processing
Disabled user accountItems aren't moved to the archive

Default MRM Policy (built-in)

TagTypeAgeAction
Default 2 years move to archiveDPT730 daysMove to Archive
Recoverable Items 14 days move to archiveRecoverable Items14 daysMove to Archive
Personal 1 / 5 year move to archive, Never movePersonal365 / 1825 / –Move to Archive
1 Week … 5 Years Delete, Never DeletePersonal7–1825 / –Delete and Allow Recovery
Junk EmailRPT30 daysDelete and Allow Recovery

💡 If the mailbox already uses Default MRM Policy, simply enabling the archive starts moving items older than 2 years. Use a custom policy (below) when you need a different age, e.g. 3 years.


Prerequisites

RequirementDetail
Admin roleExchange Administrator / Global Administrator (or Recipient + Retention Management roles)
PowerShell moduleExchangeOnlineManagement
Archive licensingExchange Online Plan 2, Microsoft 365 E3/E5, or Exchange Online Archiving add-on (for Plan 1)
Auto-expanding archiveArchive must be enabled first; up to 1.5 TB; cannot be disabled once on
Mailbox stateAccount enabled, no Retention Hold, ELC processing enabled

Solution Flow

StepActionCmdlet
0Connect and start transcriptConnect-ExchangeOnline
1Baseline: size, oldest Inbox item, current policy, blockersGet-Mailbox, Get-MailboxStatistics, Get-MailboxFolderStatistics
2Enable Online Archive (+ auto-expanding)Enable-Mailbox -Archive / -AutoExpandingArchive
3Review tags in current policyGet-RetentionPolicy, Get-RetentionPolicyTag
4Create 3-year Move-to-Archive DPTNew-RetentionPolicyTag
5Create new policy = existing tags + new DPTNew-RetentionPolicy
6Assign policy to mailboxSet-Mailbox -RetentionPolicy
7Kick off processingStart-ManagedFolderAssistant -FullCrawl
8Monitor progressExport-MailboxDiagnosticLogs, Get-MailboxStatistics -Archive
9(Optional) RollbackSet-Mailbox -RetentionPolicy <original>

🔑 Why create a new policy instead of editing the existing one? Editing a shared policy (like Default MRM Policy) changes every mailbox using it. A cloned policy limits the change to the target mailbox(es) and keeps all their existing tags.


Full PowerShell Script

Run in PowerShell ISE / VS Code. Edit the VARIABLES block, then run each #region one step at a time (select → F8) and review the output before moving on.

<#
=====================================================================================
 Archive mailbox items older than N years (Exchange Online - MRM / Online Archive)
 - Server-side Managed Folder Assistant moves items with a "Move to Archive" DPT
 - Run each STEP individually (select region -> F8)
=====================================================================================
#>

# ====================== VARIABLES (edit these) ======================
$UserMailbox    = "user@contoso.com"                    # Target mailbox (UPN / primary SMTP)
$AgeInDays      = 1095                                  # 3 years
$NewTagName     = "Move to Archive - 3 Years"           # New DPT (MoveToArchive)
$NewPolicyName  = "MRM Policy - Archive After 3 Years"  # New retention policy
$EnableAutoExpandingArchive = $true                     # Needs E3/E5, EXO Plan 2 or EOA add-on
$LogFolder      = "C:\Temp\ArchiveMailbox"
# ====================================================================

#region STEP 0 - Prereqs and connect
if (-not (Test-Path $LogFolder)) { New-Item -Path $LogFolder -ItemType Directory | Out-Null }
$Stamp = Get-Date -Format "yyyyMMdd_HHmmss"
Start-Transcript -Path "$LogFolder\ArchiveMailbox_$Stamp.log" -Append

if (-not (Get-Module -ListAvailable -Name ExchangeOnlineManagement)) {
    Install-Module ExchangeOnlineManagement -Scope CurrentUser -Force
}
Import-Module ExchangeOnlineManagement
Connect-ExchangeOnline -ShowBanner:$false
#endregion

#region STEP 1 - BEFORE snapshot (mailbox, archive, policy, holds)
$Mbx = Get-Mailbox -Identity $UserMailbox -ErrorAction Stop
$Mbx | Select-Object DisplayName, PrimarySmtpAddress, RecipientTypeDetails, ProhibitSendQuota,
    ProhibitSendReceiveQuota, ArchiveStatus, ArchiveGuid, AutoExpandingArchiveEnabled,
    RetentionPolicy, RetentionHoldEnabled, ElcProcessingDisabled, LitigationHoldEnabled, InPlaceHolds |
    Format-List

"--- Primary mailbox size ---"
Get-MailboxStatistics -Identity $UserMailbox |
    Select-Object DisplayName, ItemCount, TotalItemSize, DeletedItemCount, TotalDeletedItemSize | Format-List

"--- Inbox stats (oldest / newest item) ---"
Get-MailboxFolderStatistics -Identity $UserMailbox -FolderScope Inbox -IncludeOldestAndNewestItems |
    Select-Object Name, FolderPath, ItemsInFolder, ItemsInFolderAndSubfolders, FolderAndSubfolderSize,
    OldestItemReceivedDate, NewestItemReceivedDate | Format-Table -AutoSize

# Save current retention policy name for rollback
$OriginalPolicy = $Mbx.RetentionPolicy
"Original retention policy: $OriginalPolicy" | Out-File "$LogFolder\OriginalPolicy_$Stamp.txt"
"Original retention policy saved: $OriginalPolicy"

# These block MFA processing - must be False
if ($Mbx.RetentionHoldEnabled -or $Mbx.ElcProcessingDisabled) {
    Write-Warning "RetentionHoldEnabled or ElcProcessingDisabled is TRUE - MFA will NOT move items. Fix in STEP 1a."
} else {
    Write-Host "Retention hold / ELC processing OK." -ForegroundColor Green
}
#endregion

#region STEP 1a - (ONLY if warning above) Re-enable MFA processing
# Set-Mailbox -Identity $UserMailbox -RetentionHoldEnabled $false
# Set-Mailbox -Identity $UserMailbox -ElcProcessingDisabled $false
#endregion

#region STEP 2 - Enable Online Archive (and auto-expanding archive)
$Mbx = Get-Mailbox -Identity $UserMailbox
if ($Mbx.ArchiveStatus -ne "Active" -and $Mbx.ArchiveGuid -eq [Guid]::Empty) {
    Enable-Mailbox -Identity $UserMailbox -Archive
    Write-Host "Online Archive enabled." -ForegroundColor Green
} else {
    Write-Host "Online Archive already enabled." -ForegroundColor Yellow
}

if ($EnableAutoExpandingArchive) {
    $Mbx = Get-Mailbox -Identity $UserMailbox
    if (-not $Mbx.AutoExpandingArchiveEnabled) {
        try {
            Enable-Mailbox -Identity $UserMailbox -AutoExpandingArchive -ErrorAction Stop
            Write-Host "Auto-expanding archive enabled (irreversible)." -ForegroundColor Green
        } catch {
            Write-Warning "Auto-expanding archive not enabled: $($_.Exception.Message)"
        }
    } else {
        Write-Host "Auto-expanding archive already enabled." -ForegroundColor Yellow
    }
}

Get-Mailbox -Identity $UserMailbox | Select-Object ArchiveStatus, ArchiveName, ArchiveQuota, AutoExpandingArchiveEnabled | Format-List
#endregion

#region STEP 3 - Review tags in the CURRENT retention policy
$CurrentPolicyName = (Get-Mailbox -Identity $UserMailbox).RetentionPolicy
if ([string]::IsNullOrEmpty($CurrentPolicyName)) { $CurrentPolicyName = "Default MRM Policy" }

$CurrentTagNames = (Get-RetentionPolicy -Identity $CurrentPolicyName).RetentionPolicyTagLinks |
    ForEach-Object { $_.ToString() }

$CurrentTags = $CurrentTagNames | ForEach-Object { Get-RetentionPolicyTag -Identity $_ }
"--- Tags in current policy '$CurrentPolicyName' ---"
$CurrentTags | Select-Object Name, Type, RetentionAction, AgeLimitForRetention, RetentionEnabled | Format-Table -AutoSize

# Move-to-Archive DPT age must be LOWER than any Delete DPT age
$DeleteDpt = $CurrentTags | Where-Object {
    $_.Type -eq "All" -and $_.RetentionAction -in @("DeleteAndAllowRecovery", "PermanentlyDelete") -and $_.RetentionEnabled
}
if ($DeleteDpt -and $DeleteDpt.AgeLimitForRetention.Days -le $AgeInDays) {
    Write-Warning "Delete DPT '$($DeleteDpt.Name)' ($($DeleteDpt.AgeLimitForRetention.Days) days) is not longer than $AgeInDays days. Review before continuing."
} else {
    Write-Host "No conflicting Delete DPT." -ForegroundColor Green
}
#endregion

#region STEP 4 - Create the Move-to-Archive Default Policy Tag
if (-not (Get-RetentionPolicyTag -Identity $NewTagName -ErrorAction SilentlyContinue)) {
    New-RetentionPolicyTag -Name $NewTagName `
        -Type All `
        -RetentionAction MoveToArchive `
        -AgeLimitForRetention $AgeInDays `
        -RetentionEnabled $true `
        -Comment "Moves items older than $AgeInDays days to the Online Archive"
    Write-Host "Tag '$NewTagName' created." -ForegroundColor Green
} else {
    Write-Host "Tag '$NewTagName' already exists." -ForegroundColor Yellow
}
Get-RetentionPolicyTag -Identity $NewTagName | Select-Object Name, Type, RetentionAction, AgeLimitForRetention, RetentionEnabled | Format-List
#endregion

#region STEP 5 - Create new retention policy (existing tags + new DPT)
# Only one Move-to-Archive DPT is allowed per policy - drop the old one
$TagsToKeep = $CurrentTags | Where-Object {
    -not ($_.Type -eq "All" -and $_.RetentionAction -eq "MoveToArchive")
} | Select-Object -ExpandProperty Name

$NewPolicyTags = @($TagsToKeep) + $NewTagName | Select-Object -Unique
"Tags for new policy:"; $NewPolicyTags

if (-not (Get-RetentionPolicy -Identity $NewPolicyName -ErrorAction SilentlyContinue)) {
    New-RetentionPolicy -Name $NewPolicyName -RetentionPolicyTagLinks $NewPolicyTags
    Write-Host "Policy '$NewPolicyName' created." -ForegroundColor Green
} else {
    Set-RetentionPolicy -Identity $NewPolicyName -RetentionPolicyTagLinks $NewPolicyTags -Confirm:$false
    Write-Host "Policy '$NewPolicyName' already existed - tag links updated." -ForegroundColor Yellow
}
(Get-RetentionPolicy -Identity $NewPolicyName).RetentionPolicyTagLinks
#endregion

#region STEP 6 - Assign the policy to the mailbox
Set-Mailbox -Identity $UserMailbox -RetentionPolicy $NewPolicyName
Get-Mailbox -Identity $UserMailbox | Select-Object DisplayName, RetentionPolicy, ArchiveStatus | Format-List
#endregion

#region STEP 7 - Start the Managed Folder Assistant
# If the archive was just enabled and this errors, wait 15-30 min and re-run.
Start-ManagedFolderAssistant -Identity $UserMailbox -FullCrawl
Write-Host "MFA started. Large mailboxes may need several cycles - re-run daily if required." -ForegroundColor Cyan
#endregion

#region STEP 8 - Monitor progress (re-run after a few hours / next day)
$Diag = [xml](Export-MailboxDiagnosticLogs -Identity $UserMailbox -ExtendedProperties).MailboxLog
$Diag.Properties.MailboxTable.Property |
    Where-Object { $_.Name -like "ELC*" } |
    Select-Object Name, Value | Format-Table -AutoSize

"--- Primary mailbox ---"
Get-MailboxStatistics -Identity $UserMailbox | Select-Object ItemCount, TotalItemSize | Format-List

"--- Archive mailbox ---"
Get-MailboxStatistics -Identity $UserMailbox -Archive | Select-Object DisplayName, ItemCount, TotalItemSize | Format-List

"--- Inbox oldest item (should move toward the cut-off date) ---"
Get-MailboxFolderStatistics -Identity $UserMailbox -FolderScope Inbox -IncludeOldestAndNewestItems |
    Select-Object FolderPath, ItemsInFolder, FolderAndSubfolderSize, OldestItemReceivedDate | Format-Table -AutoSize
#endregion

#region STEP 9 - (OPTIONAL) Rollback to the original policy
# $OriginalPolicyName = Get-Content (Get-ChildItem "$LogFolder\OriginalPolicy_*.txt" | Sort-Object LastWriteTime | Select-Object -Last 1).FullName
# $OriginalPolicyName = $OriginalPolicyName -replace "Original retention policy: ",""
# Set-Mailbox -Identity $UserMailbox -RetentionPolicy $OriginalPolicyName
# Note: items already moved stay in the archive; the user can move them back if needed.
#endregion

#region STEP 10 - Disconnect
Stop-Transcript
Disconnect-ExchangeOnline -Confirm:$false
#endregion

Verifying Results

CheckWhat "good" looks like
ELCLastSuccessTimestamp (Step 8)Recent date/time after you started MFA
Primary TotalItemSizeDecreasing
Archive ItemCount / TotalItemSizeIncreasing
Inbox OldestItemReceivedDateMoves closer to today − N years
Outlook / OWAOnline Archive folder visible with same folder structure

Troubleshooting

SymptomLikely causeFix
Nothing movesArchive not enabledEnable-Mailbox -Archive
Nothing movesRetentionHoldEnabled / ElcProcessingDisabled = TrueSet both to $false
Nothing movesUser account disabledItems aren't archived for disabled accounts
Start-ManagedFolderAssistant errorsArchive still provisioningWait 15–30 min, re-run
Only part of mailbox movedLarge mailbox, MFA works in batchesRe-run Step 7 daily; check Step 8
Some folders not archivedPersonal tag "Never move to archive" applied by userExpected – personal tags override the DPT
New-RetentionPolicy failsTwo Move-to-Archive DPTs, or archive age ≥ delete ageKeep one archive DPT; archive age must be lower
Auto-expanding failsLicense doesn't include archivingAssign EXO Plan 2 / E3 / E5 or EOA add-on

Key Takeaways

#Takeaway
1Don't use Outlook rules for bulk archiving – use server-side MRM
2Move to Archive works only with DPTs or Personal tags – not with folder RPTs
3Clone the current policy instead of editing a shared one
4Always baseline first and save the original policy for rollback
5Use Start-ManagedFolderAssistant -FullCrawl to avoid waiting up to 7 days
6Auto-expanding archive is irreversible – confirm licensing and intent first

References (Microsoft Learn)

TopicLink
Retention tags and retention policieshttps://learn.microsoft.com/exchange/security-and-compliance/messaging-records-management/retention-tags-and-policies
Default folders that support RPTshttps://learn.microsoft.com/exchange/security-and-compliance/messaging-records-management/default-folders
Default Retention Policy (Default MRM Policy)https://learn.microsoft.com/exchange/security-and-compliance/messaging-records-management/default-retention-policy
How retention age is calculatedhttps://learn.microsoft.com/exchange/security-and-compliance/messaging-records-management/retention-age
Set up an archive and deletion policyhttps://learn.microsoft.com/purview/set-up-an-archive-and-deletion-policy-for-mailboxes
Enable archive mailboxeshttps://learn.microsoft.com/purview/enable-archive-mailboxes
Enable auto-expanding archivinghttps://learn.microsoft.com/purview/enable-autoexpanding-archiving
Start-ManagedFolderAssistanthttps://learn.microsoft.com/powershell/module/exchangepowershell/start-managedfolderassistant
New-RetentionPolicyTaghttps://learn.microsoft.com/powershell/module/exchangepowershell/new-retentionpolicytag
Enable-Mailboxhttps://learn.microsoft.com/powershell/module/exchangepowershell/enable-mailbox


graph sharepoint


SET TenantId TO $'''5a2c87d9-ad1d-4d88-85ef-f5a3c30ca040'''

SET ClientId TO $'''473fd5ed-08fe-4c90-82a6-d3180546e5fd'''

SET ClientSecret TO $'''StB8Q~lB86O-7SxBKUm6Jjzf0guSFYtYqWrTBcv3'''

SET SiteHost TO $'''sreekanth10.sharepoint.com'''

SET SitePath TO $'''/sites/pub'''

SET TargetFileName TO $'''customlistcss.css'''

SET DownloadFolderPath TO $'''C:\\Users\\usree\\Downloads\\graphdownlaod'''

SET UA TO $'''Mozilla/5.0 (Windows NT 10.0; Win64; x64)'''

Web.InvokeWebService.InvokeWebService Url: $'''https://login.microsoftonline.com/%TenantId%/oauth2/v2.0/token''' Method: Web.Method.Post Accept: $'''application/json''' ContentType: $'''application/x-www-form-urlencoded''' RequestBody: $'''client_id=%ClientId%&client_secret=%ClientSecret%&scope=https%%3A%%2F%%2Fgraph.microsoft.com%%2F.default&grant_type=client_credentials''' ConnectionTimeout: 30 FollowRedirection: True ClearCookies: False FailOnErrorStatus: True EncodeRequestBody: False UserAgent: UA Encoding: Web.Encoding.AutoDetect AcceptUntrustedCertificates: False ResponseHeaders=> TokenHeaders Response=> TokenResponse StatusCode=> TokenStatus

Variables.ConvertJsonToCustomObject Json: TokenResponse CustomObject=> TokenObj

SET AccessToken TO TokenObj['access_token']

Web.InvokeWebService.InvokeWebService Url: $'''https://graph.microsoft.com/v1.0/sites/%SiteHost%:%SitePath%''' Method: Web.Method.Get Accept: $'''application/json''' ContentType: $'''application/json''' CustomHeaders: $'''Authorization: Bearer %AccessToken%''' ConnectionTimeout: 30 FollowRedirection: True ClearCookies: False FailOnErrorStatus: True EncodeRequestBody: False UserAgent: UA Encoding: Web.Encoding.AutoDetect AcceptUntrustedCertificates: False ResponseHeaders=> SiteHeaders Response=> SiteResponse StatusCode=> SiteStatus

Variables.ConvertJsonToCustomObject Json: SiteResponse CustomObject=> SiteObj

SET SiteId TO SiteObj['id']

Web.InvokeWebService.InvokeWebService Url: $'''https://graph.microsoft.com/v1.0/sites/%SiteId%/drive/root:/%TargetFileName%''' Method: Web.Method.Get Accept: $'''application/json''' ContentType: $'''application/json''' CustomHeaders: $'''Authorization: Bearer %AccessToken%''' ConnectionTimeout: 30 FollowRedirection: True ClearCookies: False FailOnErrorStatus: True EncodeRequestBody: False UserAgent: UA Encoding: Web.Encoding.AutoDetect AcceptUntrustedCertificates: False ResponseHeaders=> ItemHeaders Response=> ItemResponse StatusCode=> ItemStatus

Variables.ConvertJsonToCustomObject Json: ItemResponse CustomObject=> ItemObj

SET FileName TO ItemObj['name']

SET DownloadUrl TO ItemObj['@microsoft.graph.downloadUrl']

Web.DownloadFromWeb.DownloadToFile Url: DownloadUrl FilePath: $'''%DownloadFolderPath%\\%FileName%''' ConnectionTimeout: 120 FollowRedirection: True ClearCookies: False UserAgent: UA Encoding: Web.Encoding.AutoDetect AcceptUntrustedCertificates: False DownloadedFile=> DownloadedFile

Display.ShowMessageDialog.ShowMessage Title: $'''SharePoint download''' Message: $'''Downloaded: %DownloadedFile%''' Icon: Display.Icon.Information Buttons: Display.Buttons.OK DefaultButton: Display.DefaultButton.Button1 IsTopMost: True ButtonPressed=> ButtonPressed2


===============================================================

SET TenantId TO $'''5a2c87d9-ad1d-4d88-85ef-f5a3c30ca040'''

SET ClientId TO $'''473fd5ed-08fe-4c90-82a6-d3180546e5fd'''

SET ClientSecret TO $'''StB8Q~lB86O-7SxBKUm6Jjzf0guSFYtYqWrTBcv3'''

SET SiteHost TO $'''sreekanth10.sharepoint.com'''

SET SitePath TO $'''/sites/pub'''

SET TargetFileName TO $''''''

SET DownloadFolderPath TO $'''C:\\Users\\usree\\Downloads\\graphdownlaod'''

SET UA TO $'''Mozilla/5.0 (Windows NT 10.0; Win64; x64)'''

Web.InvokeWebService.InvokeWebService Url: $'''https://login.microsoftonline.com/%TenantId%/oauth2/v2.0/token''' Method: Web.Method.Post Accept: $'''application/json''' ContentType: $'''application/x-www-form-urlencoded''' RequestBody: $'''client_id=%ClientId%&client_secret=%ClientSecret%&scope=https%%3A%%2F%%2Fgraph.microsoft.com%%2F.default&grant_type=client_credentials''' ConnectionTimeout: 30 FollowRedirection: True ClearCookies: False FailOnErrorStatus: True EncodeRequestBody: False UserAgent: UA Encoding: Web.Encoding.AutoDetect AcceptUntrustedCertificates: False ResponseHeaders=> TokenHeaders Response=> TokenResponse StatusCode=> TokenStatus

Variables.ConvertJsonToCustomObject Json: TokenResponse CustomObject=> TokenObj

SET AccessToken TO TokenObj['access_token']

Web.InvokeWebService.InvokeWebService Url: $'''https://graph.microsoft.com/v1.0/sites/%SiteHost%:%SitePath%''' Method: Web.Method.Get Accept: $'''application/json''' ContentType: $'''application/json''' CustomHeaders: $'''Authorization: Bearer %AccessToken%''' ConnectionTimeout: 30 FollowRedirection: True ClearCookies: False FailOnErrorStatus: True EncodeRequestBody: False UserAgent: UA Encoding: Web.Encoding.AutoDetect AcceptUntrustedCertificates: False ResponseHeaders=> SiteHeaders Response=> SiteResponse StatusCode=> SiteStatus

Variables.ConvertJsonToCustomObject Json: SiteResponse CustomObject=> SiteObj

SET SiteId TO SiteObj['id']

Web.InvokeWebService.InvokeWebService Url: $'''https://graph.microsoft.com/v1.0/sites/%SiteId%/drive''' Method: Web.Method.Get Accept: $'''application/json''' ContentType: $'''application/json''' CustomHeaders: $'''Authorization: Bearer %AccessToken%''' ConnectionTimeout: 30 FollowRedirection: True ClearCookies: False FailOnErrorStatus: True EncodeRequestBody: False UserAgent: UA Encoding: Web.Encoding.AutoDetect AcceptUntrustedCertificates: False ResponseHeaders=> DriveHeaders Response=> DriveResponse StatusCode=> DriveStatus

Variables.ConvertJsonToCustomObject Json: DriveResponse CustomObject=> DriveObj

SET DriveId TO DriveObj['id']

SET FileName TO $''''''

SET DownloadUrl TO $''''''

IF IsEmpty(TargetFileName) THEN

    Web.InvokeWebService.InvokeWebService Url: $'''https://graph.microsoft.com/v1.0/drives/%DriveId%/root/children''' Method: Web.Method.Get Accept: $'''application/json''' ContentType: $'''application/json''' CustomHeaders: $'''Authorization: Bearer %AccessToken%''' ConnectionTimeout: 30 FollowRedirection: True ClearCookies: False FailOnErrorStatus: True EncodeRequestBody: False UserAgent: UA Encoding: Web.Encoding.AutoDetect AcceptUntrustedCertificates: False ResponseHeaders=> ListHeaders Response=> ListResponse StatusCode=> ListStatus

    Variables.ConvertJsonToCustomObject Json: ListResponse CustomObject=> ListObj

    LOOP FOREACH CurrentItem IN ListObj['value']

        Variables.ConvertCustomObjectToJson CustomObject: CurrentItem Json=> ItemJson

        IF Contains(ItemJson, $'''\"file\"''', False) THEN

            SET FileName TO CurrentItem['name']

            SET DownloadUrl TO CurrentItem['@microsoft.graph.downloadUrl']

            EXIT LOOP

        END

    END

ELSE

    Web.InvokeWebService.InvokeWebService Url: $'''https://graph.microsoft.com/v1.0/drives/%DriveId%/root:/%TargetFileName%''' Method: Web.Method.Get Accept: $'''application/json''' ContentType: $'''application/json''' CustomHeaders: $'''Authorization: Bearer %AccessToken%''' ConnectionTimeout: 30 FollowRedirection: True ClearCookies: False FailOnErrorStatus: True EncodeRequestBody: False UserAgent: UA Encoding: Web.Encoding.AutoDetect AcceptUntrustedCertificates: False ResponseHeaders=> ItemHeaders Response=> ItemResponse StatusCode=> ItemStatus

    Variables.ConvertJsonToCustomObject Json: ItemResponse CustomObject=> ItemObj

    SET FileName TO ItemObj['name']

    SET DownloadUrl TO ItemObj['@microsoft.graph.downloadUrl']

END

IF IsEmpty(DownloadUrl) THEN

    Display.ShowMessageDialog.ShowMessage Title: $'''SharePoint download''' Message: $'''No file found in the library root.''' Icon: Display.Icon.ErrorIcon Buttons: Display.Buttons.OK DefaultButton: Display.DefaultButton.Button1 IsTopMost: True ButtonPressed=> ButtonPressed

    EXIT Code: 0

END

Web.DownloadFromWeb.DownloadToFile Url: DownloadUrl FilePath: $'''%DownloadFolderPath%\\%FileName%''' ConnectionTimeout: 120 FollowRedirection: True ClearCookies: False UserAgent: UA Encoding: Web.Encoding.AutoDetect AcceptUntrustedCertificates: False DownloadedFile=> DownloadedFile

Display.ShowMessageDialog.ShowMessage Title: $'''SharePoint download''' Message: $'''Downloaded: %DownloadedFile%''' Icon: Display.Icon.Information Buttons: Display.Buttons.OK DefaultButton: Display.DefaultButton.Button1 IsTopMost: True ButtonPressed=> ButtonPressed2


Wednesday, October 7, 2026

Teams Meeting Policy

Teams Meeting Policy: Auto-Recording, Transcription, Downloads and Never-Expiring Recordings


What You Will Configure

RequirementAdmin controlWhere
Allow meeting recording-AllowCloudRecording $trueTeams admin center or PowerShell
Allow transcription-AllowTranscription $trueTeams admin center or PowerShell
Record and transcribe automatically-AutoRecording EnabledPowerShell only
Recordings never expire-NewMeetingRecordingExpirationDays -1PowerShell only (or turn off "Recordings automatically expire" in the Teams admin center)
Participants can download recordings and transcriptsNo per-user admin policyMeeting options (organizer) + tenant download settings

Key Facts

TopicBehaviour
AutoRecording policyOnly shows the Record and transcribe automatically toggle in Meeting options
Default toggle stateOff for meetings, On for webinars and town halls
Force auto-record on every meetingNeeds a Teams Premium meeting template or a sensitivity label
Custom meeting templatesCreated in the Teams admin center only; no PowerShell cmdlet
Default recording expiry120 days
Expiry range1–99,999 days, or -1 (never) in PowerShell
Expiry change scopeApplies to new recordings only; existing recordings keep their expiry date
Recording storageOrganizer's OneDrive (non-channel meetings), channel SharePoint site (channel meetings)
Default download rightsOrganizers and co-organizers can download; other invitees can only view
"Who has access to the recording and transcript"Per-meeting option; needs Teams Premium or Copilot license
Tenant-wide download blockSet-SPOTenant -BlockDownloadFileTypePolicy with TeamsMeetingRecording overrides everything
Policy propagationCan take several hours after assignment
Legal retentionUse Microsoft Purview retention policies, not the expiry setting

Prerequisites

ItemDetail
RolesTeams Administrator; SharePoint Administrator (download check); Graph User.Read.All (license check)
ModulesMicrosoftTeams, Microsoft.Online.SharePoint.PowerShell, Microsoft.Graph.Users
Best practiceNever edit the Global policy; duplicate it and assign the copy to the target users

Implementation Steps

  1. Connect to Microsoft Teams PowerShell.
  2. Duplicate the Global meeting policy into a new custom policy.
  3. Enable recording, transcription and auto-recording, and set expiry to never.
  4. Assign the custom policy to the user.
  5. Verify the policy values and the assignment.
  6. Check the tenant does not block downloads of meeting recordings.
  7. Check for a Teams Premium license if auto-record must be enforced.
  8. Test with a short meeting after the policy has propagated.

Complete Script

<#
.SYNOPSIS
    Copies the Global Teams meeting policy into a custom policy, enables auto-recording,
    transcription and never-expiring recordings, assigns it to a user and runs checks.
.NOTES
    Replace the placeholder values before running.
#>

# ---------------- Variables ----------------
$sourcePolicy = "Global"
$newPolicy    = "MeetingPolicy-AutoRecord-NoExpiry"
$userUpn      = "user@contoso.com"
$spoAdminUrl  = "https://contoso-admin.sharepoint.com"

# ---------------- 1. Connect ----------------
Connect-MicrosoftTeams

# ---------------- 2. Copy Global into a new policy ----------------
$source = Get-CsTeamsMeetingPolicy -Identity $sourcePolicy -ErrorAction Stop

if (Get-CsTeamsMeetingPolicy -Identity $newPolicy -ErrorAction SilentlyContinue) {
    Write-Host "Policy '$newPolicy' already exists - syncing settings from '$sourcePolicy'." -ForegroundColor Yellow
} else {
    New-CsTeamsMeetingPolicy -Identity $newPolicy -Description "Copy of Global + auto-record + no recording expiry" -ErrorAction Stop | Out-Null
    Write-Host "Created policy '$newPolicy'." -ForegroundColor Green
}

$target = Get-CsTeamsMeetingPolicy -Identity $newPolicy
$skip   = @('Identity', 'Description', 'Tenant', 'Force', 'InMemory')
$common = [System.Management.Automation.PSCmdlet]::CommonParameters + [System.Management.Automation.PSCmdlet]::OptionalCommonParameters
$params = (Get-Command Set-CsTeamsMeetingPolicy).Parameters.Keys | Where-Object { $_ -notin $skip -and $_ -notin $common }

$copied = 0
$failed = @()
foreach ($p in $params) {
    if ($source.PSObject.Properties.Name -notcontains $p) { continue }
    if ("$($source.$p)" -eq "$($target.$p)") { continue }
    try {
        $splat = @{ Identity = $newPolicy; $p = $source.$p; ErrorAction = 'Stop' }
        Set-CsTeamsMeetingPolicy @splat
        $copied++
    } catch {
        $failed += [pscustomobject]@{ Setting = $p; Value = "$($source.$p)"; Error = $_.Exception.Message }
    }
}
Write-Host "Copied $copied setting(s) from '$sourcePolicy'." -ForegroundColor Green
if ($failed) {
    Write-Host "Settings not copied (usually retired parameters):" -ForegroundColor Yellow
    $failed | Format-Table -AutoSize
} else {
    Write-Host "No copy failures." -ForegroundColor Green
}

# ---------------- 3. Apply required settings ----------------
Set-CsTeamsMeetingPolicy -Identity $newPolicy `
    -AllowCloudRecording $true `
    -AllowTranscription $true `
    -AutoRecording Enabled `
    -NewMeetingRecordingExpirationDays -1

# ---------------- 4. Assign to the user ----------------
Grant-CsTeamsMeetingPolicy -Identity $userUpn -PolicyName $newPolicy

# ---------------- 5. Verify ----------------
Get-CsTeamsMeetingPolicy -Identity $newPolicy |
    Select-Object Identity, AllowCloudRecording, AllowTranscription, AutoRecording, NewMeetingRecordingExpirationDays
Get-CsUserPolicyAssignment -Identity $userUpn -PolicyType TeamsMeetingPolicy

# ---------------- 6. Check tenant-wide recording download block ----------------
Connect-SPOService -Url $spoAdminUrl
$spo = Get-SPOTenant | Select-Object BlockDownloadFileTypePolicy, BlockDownloadFileTypeIds
if ($spo.BlockDownloadFileTypePolicy -and ($spo.BlockDownloadFileTypeIds -contains "TeamsMeetingRecording")) {
    Write-Host "Recording downloads are BLOCKED tenant-wide - participants cannot download." -ForegroundColor Red
} else {
    Write-Host "No tenant-wide block on meeting recording downloads." -ForegroundColor Green
}

# ---------------- 7. Check Teams Premium (needed to enforce auto-record) ----------------
Connect-MgGraph -Scopes "User.Read.All" -NoWelcome
$premium = Get-MgUserLicenseDetail -UserId $userUpn |
    ForEach-Object { $_.ServicePlans } |
    Where-Object { $_.ServicePlanName -like "TEAMSPRO*" -and $_.ProvisioningStatus -eq "Success" }
if ($premium) {
    Write-Host "Teams Premium found - a locked meeting template can enforce auto-record." -ForegroundColor Green
} else {
    Write-Host "No Teams Premium - organizer must switch the toggle on per meeting." -ForegroundColor Yellow
}

# ---------------- Rollback (run only if needed) ----------------
# Grant-CsTeamsMeetingPolicy -Identity $userUpn -PolicyName $null   # revert user to Global
# Remove-CsTeamsMeetingPolicy -Identity $newPolicy                   # delete custom policy

Organizer Actions After Policy Applies

GoalAction
Auto-record a meetingMeeting options → Record and transcribe automatically → On (per meeting or per recurring series)
Let attendees downloadAdd them as co-organizers, or use Who has access to the recording and transcript (Premium/Copilot)
Share with othersShare the .mp4 / transcript from OneDrive with the required permission
Change expiry on an old recordingOpen the file details in OneDrive and edit the expiration date

Validation Checklist

  • AllowCloudRecording = True
  • AllowTranscription = True
  • AutoRecording = Enabled
  • NewMeetingRecordingExpirationDays = -1
  • User assigned the custom policy
  • Toggle visible in Meeting options of a new meeting
  • Test meeting records and transcribes automatically
  • Recording shows no expiration date
  • Participant can download recording and transcript

Troubleshooting

SymptomCauseFix
Toggle not visiblePolicy not propagated or not assignedWait a few hours; sign out and in; check Get-CsUserPolicyAssignment
Toggle visible but meeting not recordedToggle is Off by defaultOrganizer switches it On per meeting
Auto-record cannot be enforcedNo Teams PremiumUse a sensitivity label or license Teams Premium
Recording still shows an expiry dateRecording created before the changeEdit expiry on the file manually
Participants cannot downloadDefault view-only rights or tenant download blockMake co-organizers / share file / review BlockDownloadFileTypePolicy
A setting failed to copyRetired or read-only parameterReview and ignore if not relevant

References

Featured Post

Exchange Online - Archive Emails

Archive Emails Older Than X Years in Exchange Online – The Server-Side Way (MRM + PowerShell) Scenario: A user's mailbox is almost full...

Popular posts