Short answer: run .\Invoke-M365Offboarding.ps1 -UserPrincipalName bob@contoso.com -ConvertToShared -ForwardToManager -Apply -WhatIf to see exactly what would happen, then run it again without -WhatIf. In one logged run it blocks sign-in, revokes sessions, converts the mailbox to shared, sets an auto-reply, forwards new mail to the manager, removes group memberships and removes direct licences, after saving a JSON snapshot of the user’s licences, groups and mailbox settings. Before any group or licence change it works out whether the mailbox still needs its Exchange licence; if it does, the licence and the groups that assign it are kept. Without -Apply it only shows the plan.
Version 1.1.0 (7 October 2026): An external review found that version 1.0.0 could remove the user from a group that gave the mailbox its Exchange licence, even when the shared mailbox still needed that licence (over 50 GB or on hold). Version 1.1.0 decides which licences must stay before it changes any group or licence, and keeps licence-assigning groups whenever the mailbox still needs a licence or a fact cannot be read.
Commands checked against the official documentation (linked below) on 6 October 2026; not yet run on our lab servers. The script was syntax-checked with PowerShell 7.4.6 and PSScriptAnalyzer 1.25 (no errors or warnings) but has not yet been run against a live Microsoft 365 tenant. Every cmdlet, endpoint, property and permission it uses was checked against Microsoft Learn. The 1.1.0 licence decision logic was tested offline on 7 October 2026 with 28 synthetic cases (group-only and mixed licences, over 50 GB, holds, archive, failed or declined conversion, unknown facts, -SkipLicenseRemoval, -ForceLicenseRemoval, -WhatIf), and all 28 pass. The changes themselves were mocked: the apply mode has still not been run against a tenant. Start with the plan-only run and -Apply -WhatIf, and test on a test user first. If something behaves differently for you, tell us and we will fix the script.
Table of Contents
This script changes your tenant. Always run it with -Apply -WhatIf first and read the plan. Keep the snapshot JSON and log CSV it writes: they are your record of what the user had and what was removed. Licence removal starts Microsoft’s 30-day countdown to deleting the mailbox data, so the script only removes licences, and only removes the user from groups that assign an Exchange licence, when the mailbox no longer needs one. -ForceLicenseRemoval overrides only the “not converted to shared” check, never size, hold, archive or unknown facts.
What it does
It automates the Microsoft 365 part of our employee offboarding checklist for one user, in a fixed order, with every change going through PowerShell’s ShouldProcess:
| Step | How | Notes |
|---|---|---|
| 1. Snapshot | JSON file | Licences (direct and group-based), groups, admin roles, mailbox type, size, litigation and in-place holds, archive status, organisation-wide holds and forwarding settings |
| 2. Block sign-in | PATCH /users/{id} with accountEnabled=false | Skipped for users synced from on-premises AD: disable them in AD |
| 3. Revoke sessions | POST /users/{id}/revokeSignInSessions | Invalidates refresh tokens and browser session cookies |
| 4. Convert to shared | Set-Mailbox -Type Shared | Only with -ConvertToShared; hybrid (synced) users are listed for manual conversion. After conversion the mailbox is read again to confirm it is now shared |
| 5. Auto-reply | Set-MailboxAutoReplyConfiguration | Only with -AutoReplyMessage; internal and external message |
| 6. Forward to manager | Set-Mailbox -ForwardingAddress -DeliverToMailboxAndForward $true | Only with -ForwardToManager (manager from Entra ID) or -ForwardTo |
| 7. Licence plan | Fresh licence read (with -Apply) and the mailbox facts | Before any group or licence change: decides whether the mailbox still needs its Exchange licence. It does if it is not confirmed as shared, is over 50 GB, is on any hold, has an archive, or if any of these facts cannot be read |
| 8. Groups | DELETE /groups/{id}/members/{userId}/$ref; Remove-DistributionGroupMember | Dynamic, synced and role-assignable groups are listed for manual follow-up. Groups that assign an Exchange licence the mailbox still needs are kept and logged as Skipped, unless an active direct licence already gives the same Exchange service plans |
| 9. Licences | POST /users/{id}/assignLicense with removeLicenses | Direct licences only, and only when the mailbox no longer needs a licence; group-based ones go with the group membership |
| 10. Log | CSV | Every step: Planned, Done, Skipped, Manual, NotRun (WhatIf/declined) or Failed |
It deliberately does not reset the password, wipe devices, transfer OneDrive, remove admin roles or delete the account. Admin roles are reported as a warning, because removing them needs a more privileged role and a human decision.
Requirements
- Windows PowerShell 5.1 or PowerShell 7;
Microsoft.Graph.Authentication;ExchangeOnlineManagement3.x for the mailbox steps, distribution lists and-ForceLicenseRemoval. Without the Exchange connection the mailbox facts are unknown, so Exchange licences and the groups that assign them are kept. - Delegated Graph scopes with
-Apply:User.Read.All,User.EnableDisableAccount.All(Microsoft’s least privileged permission foraccountEnabled, together with User.Read.All),User.RevokeSessions.All,LicenseAssignment.ReadWrite.All,GroupMember.ReadWrite.All. Plan-only runs ask for read scopes only. - Entra role: User Administrator covers blocking, revoking, licences and group removal for ordinary users (Microsoft lists it for assignLicense and for removing group members). To block a user who is an administrator, Microsoft names Privileged Authentication Administrator as the least privileged role.
- Exchange: a role that can run
Set-Mailbox,Set-MailboxAutoReplyConfigurationandRemove-DistributionGroupMember, such as Exchange Administrator or the Recipient Management role group. The script also readsGet-OrganizationConfigfor organisation-wide holds; if that read fails, licences are kept. - The mailbox must still be licensed when you convert it: Microsoft requires a licence on a user mailbox before conversion to shared.
Download and first run
- Copy the script from the box on this page (or use the download button) and save it as
C:\Scripts\Invoke-M365Offboarding.ps1. - If you downloaded the file, clear the “downloaded from the internet” mark so the execution policy lets it run:
Unblock-File C:\Scripts\Invoke-M365Offboarding.ps1. - Install the module once, for your user:
Install-Module Microsoft.Graph.Authentication -Scope CurrentUser. - Read the built-in help, then run it once interactively and look at the result on screen before you export or schedule anything.
cd C:\Scripts
Get-Help .\Invoke-M365Offboarding.ps1 -Full
# Plan only: reads, prints, writes a snapshot, changes nothing
.\Invoke-M365Offboarding.ps1 -UserPrincipalName bob@contoso.com -ConvertToShared -ForwardToManager
Also install ExchangeOnlineManagement if you use the mailbox options. The plan-only run asks for read permissions; the first -Apply run asks you (or a Global Administrator) to consent to the write scopes.
Options
| Parameter | What it does | Default |
|---|---|---|
-UserPrincipalName | The leaver (required) | – |
-Apply | Make the changes; combine with -WhatIf for a dry run of the real path | Plan only |
-ConvertToShared | Convert the mailbox to a shared mailbox | Off |
-AutoReplyMessage | Turn on automatic replies with this text | Off |
-ExternalAudience | External auto-reply goes to None, Known (contacts) or All | All |
-ForwardToManager | Forward new mail to the manager set in Entra ID, keeping a copy | Off |
-ForwardTo | Forward to this internal recipient instead | Off |
-KeepGroup | Group names or IDs to leave the user in | None |
-SkipBlockSignIn, -SkipRevokeSessions, -SkipGroupRemoval, -SkipLicenseRemoval | Leave out a step. -SkipLicenseRemoval also keeps the user in every group that assigns a licence | All steps run |
-ForceLicenseRemoval | Remove licences (direct, and Exchange licence groups) even if the mailbox was not converted to shared. It does not override size, hold, archive or unknown facts, and needs the Exchange Online connection to read them | Off |
-LogFolder | Folder for the snapshot JSON and log CSV | Current folder |
-TenantId, -ClientId, -CertificateThumbprint, -Organization | App-only sign-in (Graph and Exchange) | Interactive |
-ExchangeAdminUpn | Pre-fill the interactive Exchange Online sign-in | None |
-Confirm:$false | Do not prompt for each step (the script uses ConfirmImpact High) | Prompt per step |
Usage examples
# 1. Dry run of the full process
.\Invoke-M365Offboarding.ps1 -UserPrincipalName bob@contoso.com -ConvertToShared -ForwardToManager `
-AutoReplyMessage "Bob has left Contoso. Please contact sales@contoso.com." -Apply -WhatIf
# 2. The real run, confirming each step
.\Invoke-M365Offboarding.ps1 -UserPrincipalName bob@contoso.com -ConvertToShared -ForwardToManager `
-AutoReplyMessage "Bob has left Contoso. Please contact sales@contoso.com." -Apply -LogFolder C:\Offboarding
# Keep one group, the licences and every group that assigns a licence
.\Invoke-M365Offboarding.ps1 -UserPrincipalName bob@contoso.com -KeepGroup "Alumni" -SkipLicenseRemoval -Apply -Confirm:$false
# Forward to a named person instead of the manager
.\Invoke-M365Offboarding.ps1 -UserPrincipalName bob@contoso.com -ConvertToShared -ForwardTo alice@contoso.com -Apply
After the run, reset the password if you need sign-in blocked immediately: Microsoft notes that blocking sign-in can take up to 24 hours to take effect and recommends a password reset for immediate effect. Revoking sessions can also take a few minutes. Use our password generator for a long random value.
Log and snapshot
No sample output is shown because the script has not yet run against a live tenant. Each run writes two files to -LogFolder:
| File | Contents |
|---|---|
Offboarding-<upn>-<time>-before.json | TakenAt, UserPrincipalName, Id, DisplayName, AccountEnabled, OnPremisesSynced, Licenses (SkuId, SkuPartNumber, AssignedByGroup, State, ExchangePlans), Groups (Id, DisplayName, Mail, type flags), DirectoryRoles, MailboxType, MailboxSizeBytes, LitigationHold, InPlaceHolds, ArchiveStatus, OrganizationHolds, ForwardingAddress, ForwardingSmtpAddress, DeliverToMailboxAndForward, PlannedForwardTarget |
Offboarding-<upn>-<time>.csv | Time, User, Step, Target, Action, Result, Detail: one row per step, group and licence. LicensePlan rows give each reason the mailbox still needs a licence and which licence groups were kept |
With the snapshot you can put things back: re-add the groups by ID, re-assign the SkuIds, or clear forwarding.
Schedule it
Offboarding is a per-person task, so it is normally run by hand or from an HR-driven workflow rather than on a timer. For automation, use app-only sign-in: an app registration with a certificate, the Graph Application permissions listed above plus Directory.Read.All (Microsoft lists it as the least privileged application permission for reading another user’s memberOf), and for the mailbox steps Exchange.ManageAsApp with an Exchange role on the app’s service principal. Microsoft documents no application permission for GET /users/{id}/manager, so in app-only mode pass -ForwardTo instead of -ForwardToManager. See Graph app-only authentication and Exchange Online app-only authentication.
.\Invoke-M365Offboarding.ps1 -UserPrincipalName bob@contoso.com -ConvertToShared -ForwardTo alice@contoso.com -Apply -Confirm:$false `
-TenantId contoso.onmicrosoft.com -ClientId <app-id> -CertificateThumbprint <thumbprint> -Organization contoso.onmicrosoft.com
How it works
- Reads the user (
GET /users/{upn}?$select=...,licenseAssignmentStates), the tenant’s SKUs for readable licence names, direct memberships (/memberOf) and, if asked, the manager. - Connects to Exchange Online when a mailbox option or
-ForceLicenseRemovalis used, then reads the mailbox, its statistics (for the 50 GB check), litigation, in-place, retention and delay holds, archive status, forwarding settings and the organisation-wide holds. - Writes the snapshot, then runs each step through
ShouldProcess: plan-only logs “Planned”,-WhatIflogs “NotRun”, a real change logs “Done” or “Failed” with the error. A failed step does not stop the others. - After the mailbox steps and before any group or licence change, it reads the licences again and builds the licence-preservation plan. The mailbox still needs its Exchange licence if it is not confirmed as shared (conversion not requested, skipped, failed or declined), is over 50 GB, is on any hold (litigation, in-place/eDiscovery, retention policy or label, delay hold), has an archive, or if any of these facts cannot be read. Unknown counts as “keep the licence”.
- Group removal uses the Graph
/$refform. Microsoft warns that without/$refa DELETE on a group member can delete the user object itself, so the script checks the URI shape before calling it. Distribution lists and mail-enabled security groups cannot be changed through Graph and go throughRemove-DistributionGroupMemberinstead. - When the mailbox still needs a licence, the user stays in the groups that give an Exchange licence; these are logged as Skipped with the reason. Such a group is only left when a fresh read shows an active direct licence with the same Exchange service plans, and that direct licence is then kept. With
-SkipLicenseRemoval, every group that assigns a licence is kept. - Licences: only direct assignments are removed, and only when the mailbox no longer needs a licence and was converted to shared, or with
-ForceLicenseRemoval, which overrides only the “not converted” check. Microsoft keeps an unlicensed user’s data for 30 days, then deletes it. - The log CSV is written in a
finallyblock, so you get it even if a step throws.
Limitations
- Shared mailbox limits. Microsoft limits an unlicensed shared mailbox to 50 GB, and a shared mailbox on litigation or in-place hold needs a licence (Exchange Online Plan 2, or Plan 1 with Exchange Online Archiving). In those cases, and when a mailbox has an archive, the script keeps the licences and the groups that assign them, and tells you why.
- No replacement licences. The script does not assign a licence itself. A group that gives an Exchange licence is only left when the user already has a matching active direct licence; otherwise remove that membership by hand later.
- Licence checks are simple. An “Exchange licence” means any
EXCHANGE_*service plan except Foundation and Analytics. The script does not check whether a plan gives enough capacity (for example Plan 1 versus Plan 2 for 50-100 GB mailboxes). Group-based licence changes are processed asynchronously by Entra ID, so the check after the group phase may still see a licence from a group that was just removed. - Organisation-wide retention policies count as a hold unless the mailbox has a matching exclusion. In tenants with such a policy, Exchange licences are always kept and have to be removed by hand after review.
- Hybrid users (synced from AD) are not converted in the cloud; Microsoft documents that directory sync can turn such mailboxes back into user mailboxes, so convert them through your on-premises Exchange tools.
- Existing SMTP forwarding (ForwardingSmtpAddress) is reported as a warning; check it, because it can override the new forward.
- External users and devices. Revoking sessions does not cover guest accounts, and it does not wipe phones. Use Intune retire or wipe for company data on devices.
- OneDrive files are not transferred; give the manager access through the Microsoft 365 admin center before the account is deleted.
- Not yet run against a live tenant (see the note at the top). The 1.1.0 decision logic was tested offline only, with 28 synthetic cases. Start with the plan and
-Apply -WhatIf, and test on a test user first.
Official documentation: Remove a former employee: block sign-in · Convert a user mailbox to a shared mailbox · Graph: remove group member ($ref warning) · Graph: user assignLicense
Related: Employee Offboarding Checklist: Secure AD, M365 and Workspace Steps · Microsoft 365 shared mailbox vs distribution group vs Microsoft 365 Group · Secure Password Generator · Find Inactive AD Users and Computers: PowerShell Cleanup in 5 Steps
See also: Microsoft 365 MFA Status Report: PowerShell Script for Graph · Entra ID Inactive Users Report: signInActivity PowerShell Script · Windows LAPS with Intune and Entra ID: Setup and Retrieval · Intune Device Compliance Report: PowerShell Script via Graph
The script
# Microsoft 365 User Offboarding PowerShell Script (with -WhatIf) (v1.1.0) - from srvScripts.com
# Source, docs and updates: https://srvscripts.com/scripts/m365-user-offboarding/
# Copyright (c) 2026 srvScripts.com. MIT licence: if you copy, share or adapt this script, keep this notice and credit srvScripts.com.
<#
.SYNOPSIS
Microsoft 365 user offboarding: block sign-in, revoke sessions, optionally convert the mailbox to shared,
set an auto-reply and forward mail to the manager, remove group memberships and direct licences, with a
before-snapshot and a log. Shows a plan only unless you add -Apply; use -Apply -WhatIf for a dry run.
.DESCRIPTION
Default (no -Apply): reads the user, licences, group memberships and (if Exchange steps are requested)
the mailbox, then prints the plan and writes a snapshot. Nothing is changed.
With -Apply, steps run in this order. Every change goes through ShouldProcess, so -WhatIf works and
-Confirm prompts per step (ConfirmImpact High: you are prompted unless you pass -Confirm:$false).
1. Snapshot JSON file with the user's licences (direct and group-based), groups and mailbox details.
2. Block sign-in PATCH /users/{id} accountEnabled=false. Skipped for users synced from on-premises
AD (disable them in AD; the change syncs up).
3. Revoke sessions POST /users/{id}/revokeSignInSessions (refresh tokens and session cookies).
4. Convert mailbox Set-Mailbox -Type Shared (only with -ConvertToShared; Exchange Online).
Microsoft: the mailbox must still be licensed when converted; an unlicensed shared
mailbox is limited to 50 GB; litigation/in-place hold on a shared mailbox needs a licence.
Users synced from on-premises AD in an Exchange hybrid are left for manual conversion.
After conversion the mailbox is read again to confirm it is now shared.
5. Auto-reply Set-MailboxAutoReplyConfiguration (only with -AutoReplyMessage).
5b. Forwarding Set-Mailbox -ForwardingAddress <manager> -DeliverToMailboxAndForward $true (only with
-ForwardToManager or -ForwardTo). The manager is read from GET /users/{id}/manager.
5c. Licence plan Before any group or licence change the licences are read again and the script decides
whether the mailbox still needs its Exchange licence. It does when the mailbox was not
confirmed as converted to shared (not requested, skipped, failed or declined), is over
50 GB, is on any hold (litigation, in-place/eDiscovery, retention policy or label, delay
hold), has an archive, or when any of these facts cannot be read (fail closed).
6. Groups Security and Microsoft 365 groups: DELETE /groups/{id}/members/{userId}/$ref.
Distribution lists and mail-enabled security groups: Remove-DistributionGroupMember
(needs the Exchange connection). Dynamic, on-premises-synced and role-assignable
groups are skipped and listed for manual follow-up. Groups that assign an Exchange
licence the mailbox still needs are kept, unless the user already has an active direct
licence with the same Exchange service plans. With -SkipLicenseRemoval every group
that assigns a licence is kept.
7. Licences Direct-assigned licences only (group-based licences go with the group membership),
via POST /users/{id}/assignLicense. Only done when the mailbox no longer needs a
licence (step 5c) and was converted to shared, or when -ForceLicenseRemoval is used,
because Microsoft deletes a mailbox's mail, contacts and calendar 30 days after its
licence is removed.
8. Log CSV with every planned/applied/skipped/failed step.
The script does NOT reset the password, wipe devices, delete the account, change OneDrive access or
remove admin roles (admin roles are reported as a warning). Microsoft notes that blocking sign-in can
take up to 24 hours to take effect and recommends a password reset for immediate effect.
Microsoft Graph permissions (delegated scopes requested by the script):
Plan only: User.Read.All, GroupMember.Read.All, LicenseAssignment.Read.All
-Apply: User.Read.All, User.EnableDisableAccount.All, User.RevokeSessions.All,
LicenseAssignment.ReadWrite.All, GroupMember.ReadWrite.All
Entra role: User Administrator covers block, revoke, licences and group membership for normal users.
To block an administrator, Microsoft lists Privileged Authentication Administrator as the
least privileged role.
App-only (certificate): the same Graph permissions as Application permissions plus Directory.Read.All
(listing another user's memberOf app-only needs it). Microsoft documents no application permission for
GET /users/{id}/manager, so with app-only sign-in pass -ForwardTo instead of -ForwardToManager.
Exchange steps app-only: Exchange.ManageAsApp and an Entra role such as Exchange Administrator on the
app's service principal, plus -Organization.
.PARAMETER UserPrincipalName The user to offboard.
.PARAMETER Apply Make the changes. Without it the script only shows the plan.
.PARAMETER ConvertToShared Convert the user's mailbox to a shared mailbox (Exchange Online).
.PARAMETER AutoReplyMessage Turn on automatic replies with this text (internal and external).
.PARAMETER ExternalAudience Who gets the external auto-reply: None, Known or All (default All).
.PARAMETER ForwardToManager Forward new mail to the user's manager (from Entra ID) and keep a copy in the mailbox.
.PARAMETER ForwardTo Forward new mail to this internal recipient instead (UPN or SMTP address).
.PARAMETER KeepGroup Group display names or object IDs to leave the user in.
.PARAMETER SkipBlockSignIn Do not block sign-in.
.PARAMETER SkipRevokeSessions Do not revoke sessions.
.PARAMETER SkipGroupRemoval Do not remove group memberships.
.PARAMETER SkipLicenseRemoval Do not remove licences. Also keeps the user in every group that assigns a licence.
.PARAMETER ForceLicenseRemoval Remove licences (direct, and Exchange licence groups) even if the mailbox was not
converted to shared. It does not override size, hold, archive or unknown facts.
Needs the Exchange connection to read those facts.
.PARAMETER LogFolder Folder for the snapshot JSON and log CSV (default: current folder).
.PARAMETER TenantId Tenant ID or domain (required for app-only sign-in).
.PARAMETER ClientId App registration (client) ID for app-only sign-in (Graph and Exchange).
.PARAMETER CertificateThumbprint Certificate thumbprint for app-only sign-in.
.PARAMETER Organization Tenant's initial domain (contoso.onmicrosoft.com) for app-only Exchange sign-in.
.PARAMETER ExchangeAdminUpn Admin account to pre-fill the interactive Exchange Online sign-in.
.EXAMPLE .\Invoke-M365Offboarding.ps1 -UserPrincipalName bob@contoso.com
Plan only: shows what would happen and writes a snapshot.
.EXAMPLE .\Invoke-M365Offboarding.ps1 -UserPrincipalName bob@contoso.com -ConvertToShared -AutoReplyMessage "Bob has left Contoso. Please email sales@contoso.com." -Apply
.EXAMPLE .\Invoke-M365Offboarding.ps1 -UserPrincipalName bob@contoso.com -ConvertToShared -ForwardToManager -Apply -WhatIf
.EXAMPLE .\Invoke-M365Offboarding.ps1 -UserPrincipalName bob@contoso.com -Apply -Confirm:$false -KeepGroup "All Staff Alumni" -SkipLicenseRemoval
.EXAMPLE .\Invoke-M365Offboarding.ps1 -UserPrincipalName bob@contoso.com -ConvertToShared -Apply -Confirm:$false -TenantId contoso.onmicrosoft.com -ClientId <app-id> -CertificateThumbprint <thumbprint> -Organization contoso.onmicrosoft.com
.NOTES
Name: Invoke-M365Offboarding.ps1
Purpose: Repeatable, logged Microsoft 365 leaver process (plan first, apply on request)
Source: https://srvscripts.com/scripts/m365-user-offboarding/
License: MIT
Version: 1.1.0
Changes: 1.1.0 - Groups that assign an Exchange licence the mailbox still needs (not converted, over 50 GB,
any hold, archive, or unknown facts) are no longer removed; -SkipLicenseRemoval also keeps
licence groups; conversion and licences are re-read before group and licence changes.
1.0.0 - First release.
Requires: Windows PowerShell 5.1 or PowerShell 7, Microsoft.Graph.Authentication; ExchangeOnlineManagement 3.x
for -ConvertToShared, -AutoReplyMessage, forwarding, -ForceLicenseRemoval and distribution list removal.
#>
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High', DefaultParameterSetName = 'Interactive')]
param(
[Parameter(Mandatory = $true)]
[ValidatePattern('^[^@\s]+@[^@\s]+\.[^@\s]+$')]
[string]$UserPrincipalName,
[switch]$Apply,
[switch]$ConvertToShared,
[ValidateLength(1, 8000)]
[string]$AutoReplyMessage,
[ValidateSet('None', 'Known', 'All')]
[string]$ExternalAudience = 'All',
[switch]$ForwardToManager,
[ValidatePattern('^[^@\s]+@[^@\s]+\.[^@\s]+$')]
[string]$ForwardTo,
[string[]]$KeepGroup,
[switch]$SkipBlockSignIn,
[switch]$SkipRevokeSessions,
[switch]$SkipGroupRemoval,
[switch]$SkipLicenseRemoval,
[switch]$ForceLicenseRemoval,
[string]$LogFolder = (Get-Location).Path,
[Parameter(ParameterSetName = 'Interactive')]
[Parameter(ParameterSetName = 'AppOnly', Mandatory = $true)]
[string]$TenantId,
[Parameter(ParameterSetName = 'AppOnly', Mandatory = $true)]
[ValidatePattern('^[0-9a-fA-F-]{36}$')]
[string]$ClientId,
[Parameter(ParameterSetName = 'AppOnly', Mandatory = $true)]
[ValidatePattern('^[0-9a-fA-F]{40}$')]
[string]$CertificateThumbprint,
[Parameter(ParameterSetName = 'AppOnly')]
[string]$Organization,
[Parameter(ParameterSetName = 'Interactive')]
[string]$ExchangeAdminUpn
)
Set-StrictMode -Version 2
$ErrorActionPreference = 'Stop'
$Graph = 'https://graph.microsoft.com/v1.0'
$SharedLimitBytes = 50GB
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$safeUpn = $UserPrincipalName -replace '[^A-Za-z0-9._-]', '_'
if (-not (Test-Path -LiteralPath $LogFolder)) { throw "Log folder not found: $LogFolder" }
$LogCsv = Join-Path $LogFolder "Offboarding-$safeUpn-$stamp.csv"
$SnapshotJson = Join-Path $LogFolder "Offboarding-$safeUpn-$stamp-before.json"
if ($ForwardToManager -and $ForwardTo) { throw 'Use either -ForwardToManager or -ForwardTo, not both.' }
$needExchange = $ConvertToShared -or [bool]$AutoReplyMessage -or $ForwardToManager -or [bool]$ForwardTo -or $ForceLicenseRemoval
function Write-Status([string]$Message) { Write-Information $Message -InformationAction Continue }
$log = [System.Collections.Generic.List[object]]::new()
function Add-LogEntry([string]$Step, [string]$Target, [string]$Action, [string]$Result, [string]$Detail = '') {
$e = [pscustomobject]@{ Time = (Get-Date).ToString('s'); User = $UserPrincipalName; Step = $Step; Target = $Target; Action = $Action; Result = $Result; Detail = $Detail }
$log.Add($e)
$line = "[{0}] {1}: {2} {3}{4}" -f $Result, $Step, $Action, $Target, $(if ($Detail) { " - $Detail" } else { '' })
if ($Result -eq 'Failed') { Write-Warning $line } else { Write-Status $line }
}
function Invoke-Graph {
param([string]$Method = 'GET', [string]$Uri, $Body)
$attempt = 0
while ($true) {
$attempt++
try {
$p = @{ Method = $Method; Uri = $Uri; OutputType = 'HashTable'; ErrorAction = 'Stop' }
if ($null -ne $Body) { $p.Body = ($Body | ConvertTo-Json -Depth 5 -Compress); $p.ContentType = 'application/json' }
return Invoke-MgGraphRequest @p
} catch {
if ($attempt -lt 5 -and $_.Exception.Message -match '429|TooManyRequests|503|ServiceUnavailable|504|GatewayTimeout') {
Start-Sleep -Seconds ([math]::Pow(2, $attempt) * 5); continue
}
throw
}
}
}
function Get-GraphCollection([string]$Uri) {
$next = $Uri
while ($next) {
$r = Invoke-Graph -Uri $next
if ($r['value']) { foreach ($i in $r['value']) { $i } }
$next = $r['@odata.nextLink']
}
}
function Get-Value($Hash, [string]$Key) {
if ($null -ne $Hash -and $Hash.ContainsKey($Key)) { return $Hash[$Key] }
return $null
}
function Write-LogFile {
$log | Export-Csv -Path $LogCsv -NoTypeInformation -Encoding UTF8
}
# ---- Licence preservation decisions (1.1.0) ---------------------------------------------------------------
# Exchange mailbox/archive service plans (EXCHANGE_S_STANDARD, _ENTERPRISE, _DESKLESS, _ARCHIVE_ADDON ...).
# EXCHANGE_S_FOUNDATION and EXCHANGE_ANALYTICS give no mailbox.
function Test-ExchangePlanName([string]$Name) {
return ($Name -like 'EXCHANGE_*' -and $Name -notlike 'EXCHANGE_S_FOUNDATION*' -and $Name -ne 'EXCHANGE_ANALYTICS')
}
# Builds licence rows from Graph licenseAssignmentStates. ExchangePlans lists the enabled Exchange service plans
# of the assignment; $null means unknown (SKU details not readable), which callers treat as Exchange (fail closed).
function Get-LicenseInfo($States, [hashtable]$SkuNames, [hashtable]$SkuPlans) {
foreach ($l in @($States)) {
if (-not $l) { continue }
$sku = [string]$l['skuId']
$off = @(@(Get-Value $l 'disabledPlans') | Where-Object { $_ } | ForEach-Object { [string]$_ })
$exo = $null
if ($SkuPlans.ContainsKey($sku)) { $exo = @($SkuPlans[$sku] | Where-Object { (Test-ExchangePlanName $_.Name) -and $off -notcontains $_.Id } | ForEach-Object { $_.Name }) }
[pscustomobject]@{
SkuId = $sku; SkuPartNumber = $(if ($SkuNames.ContainsKey($sku)) { $SkuNames[$sku] } else { '' })
AssignedByGroup = [string](Get-Value $l 'assignedByGroup'); State = [string](Get-Value $l 'state'); ExchangePlans = $exo
}
}
}
# Fresh read of the user's licence assignments. Returns $null when they cannot be read (callers fail closed).
function Read-LicenseState {
try {
$u = Invoke-Graph -Uri ("$Graph/users/{0}?`$select=id,licenseAssignmentStates" -f $userId)
return , @(Get-LicenseInfo (Get-Value $u 'licenseAssignmentStates') $skuNames $skuPlans)
} catch {
Write-Warning "Could not re-read the user's licences: $($_.Exception.Message)"
return $null
}
}
function Get-MailboxValue($Mailbox, [string]$Name) {
if ($null -ne $Mailbox -and $Mailbox.PSObject.Properties[$Name]) { return $Mailbox.PSObject.Properties[$Name].Value }
return $null
}
# Reasons the mailbox still needs its Exchange licence after offboarding. Facts that cannot be read are
# reasons too (Code Unknown). Only NotConverted can be overridden (-ForceLicenseRemoval).
function Get-MailboxLicenseReason($Mailbox, $MailboxBytes, $OrgHolds, [bool]$Converted, [int64]$LimitBytes = 50GB) {
$r = [System.Collections.Generic.List[object]]::new()
$add = { param($c, $t) $r.Add([pscustomobject]@{ Code = $c; Text = $t }) }
if (-not $Converted) { & $add 'NotConverted' 'Mailbox is not confirmed as shared (conversion not requested, skipped, failed or declined); removing its licence deletes mailbox data after 30 days' }
if (-not $Mailbox) { & $add 'Unknown' 'Mailbox facts unknown (mailbox not read or Exchange Online not connected)'; return $r }
if ($null -eq $MailboxBytes) { & $add 'Unknown' 'Mailbox size unknown' }
elseif ($MailboxBytes -gt $LimitBytes) { & $add 'Size' ("Mailbox is {0:N1} GB; an unlicensed shared mailbox is limited to 50 GB" -f ($MailboxBytes / 1GB)) }
foreach ($n in 'LitigationHoldEnabled', 'ComplianceTagHoldApplied', 'DelayHoldApplied', 'DelayReleaseHoldApplied') {
$v = "$(Get-MailboxValue $Mailbox $n)"
if ($v -eq 'True') { & $add 'Hold' "$n is on; a shared mailbox on hold needs a licence" }
elseif ($v -ne 'False') { & $add 'Unknown' "$n unknown" }
}
$mbxHolds = @()
if (-not $Mailbox.PSObject.Properties['InPlaceHolds']) { & $add 'Unknown' 'InPlaceHolds unknown' }
else {
$mbxHolds = @(@(Get-MailboxValue $Mailbox 'InPlaceHolds') | Where-Object { $_ } | ForEach-Object { [string]$_ })
# Entries starting with '-' are exclusions from an organisation-wide policy, not holds.
$on = @($mbxHolds | Where-Object { $_ -notlike '-*' })
if ($on.Count) { & $add 'Hold' ("In-place/eDiscovery hold or retention policy on the mailbox: {0}" -f ($on -join ', ')) }
}
if ($null -eq $OrgHolds) { & $add 'Unknown' 'Organisation-wide retention policies unknown (Get-OrganizationConfig not read)' }
else {
$org = @(@($OrgHolds) | Where-Object { "$_" -like 'mbx*' } | Where-Object {
$g = ("$_" -replace '^mbx', '') -replace ':.*$', ''
-not @($mbxHolds | Where-Object { $_ -like "-mbx$g*" }).Count })
if ($org.Count) { & $add 'Hold' ("Organisation-wide retention policy covers mailboxes: {0}" -f ($org -join ', ')) }
}
$as = "$(Get-MailboxValue $Mailbox 'ArchiveStatus')"
$ag = "$(Get-MailboxValue $Mailbox 'ArchiveGuid')"
if (-not $as) { & $add 'Unknown' 'ArchiveStatus unknown' }
elseif ($as -ne 'None' -or ($ag -and $ag -ne [guid]::Empty.ToString())) { & $add 'Archive' 'Mailbox has an archive; a shared mailbox with an archive needs a licence' }
return $r
}
# Effective licence-preservation plan, decided before any membership or licence change.
# Licenses rows read at the start; CurrentLicenses: rows re-read just before the group phase ($null = re-read failed)
# ProtectedGroupIds groups the user must stay in; NeedsLicense: direct licences must not be removed either.
function Get-LicensePreservationPlan($Licenses, $CurrentLicenses, $Reasons, [bool]$ForceRemoval, [bool]$SkipLicenseRemoval) {
$activeStates = @('Active', 'ActiveWithSolutionUpdate')
$all = @(@($Licenses) + @($CurrentLicenses) | Where-Object { $_ })
$exo = @($all | Where-Object { $null -eq $_.ExchangePlans -or @($_.ExchangePlans).Count })
$needed = @($Reasons | Where-Object { $_ -and -not ($ForceRemoval -and $_.Code -eq 'NotConverted') })
$needsLicense = ($exo.Count -gt 0 -and $needed.Count -gt 0)
$exoGroups = @($exo | Where-Object { $_.AssignedByGroup } | ForEach-Object { $_.AssignedByGroup } | Select-Object -Unique)
$anyGroups = @($all | Where-Object { $_.AssignedByGroup } | ForEach-Object { $_.AssignedByGroup } | Select-Object -Unique)
# A replacement counts only when the fresh read shows active direct licences that already give every Exchange
# service plan the licence groups give. The script does not assign replacements itself.
$verified = $false
if ($needsLicense -and $exoGroups.Count -and $null -ne $CurrentLicenses) {
$fromGroups = @($all | Where-Object { $_.AssignedByGroup -and $exoGroups -contains $_.AssignedByGroup })
$direct = @(@($CurrentLicenses) | Where-Object { $_ -and -not $_.AssignedByGroup -and $activeStates -contains $_.State -and $null -ne $_.ExchangePlans })
$want = @($fromGroups | ForEach-Object { $_.ExchangePlans } | Select-Object -Unique)
$have = @($direct | ForEach-Object { $_.ExchangePlans } | Select-Object -Unique)
$unknown = @($fromGroups | Where-Object { $null -eq $_.ExchangePlans }).Count
$verified = (-not $unknown -and $want.Count -gt 0 -and -not @($want | Where-Object { $have -notcontains $_ }).Count)
}
$protected = @(); $why = ''
if ($SkipLicenseRemoval) { $protected = $anyGroups; $why = 'Group assigns a licence and -SkipLicenseRemoval is set' }
elseif ($needsLicense -and -not $verified) {
$protected = $exoGroups
$why = 'Group assigns an Exchange licence the mailbox still needs (see LicensePlan); assign and verify a direct licence with the same Exchange plans first, or remove the membership later'
}
[pscustomobject]@{
NeedsLicense = $needsLicense; Reasons = @($(if ($needsLicense) { $needed | ForEach-Object { $_.Text } }))
ExchangeLicenseGroupIds = $exoGroups; ReplacementVerified = $verified; ProtectedGroupIds = @($protected); ProtectReason = $why
}
}
# ---- Connect to Microsoft Graph ---------------------------------------------------------------------------
foreach ($m in @('Microsoft.Graph.Authentication') + $(if ($needExchange) { 'ExchangeOnlineManagement' } else { @() })) {
if (-not (Get-Module -ListAvailable -Name $m)) { throw "Module $m is not installed. Run: Install-Module $m -Scope CurrentUser" }
}
Import-Module Microsoft.Graph.Authentication
if ($PSCmdlet.ParameterSetName -eq 'AppOnly') {
Connect-MgGraph -TenantId $TenantId -ClientId $ClientId -CertificateThumbprint $CertificateThumbprint -NoWelcome
} else {
$scopes = if ($Apply) {
'User.Read.All', 'User.EnableDisableAccount.All', 'User.RevokeSessions.All', 'LicenseAssignment.ReadWrite.All', 'GroupMember.ReadWrite.All'
} else {
'User.Read.All', 'GroupMember.Read.All', 'LicenseAssignment.Read.All'
}
$cp = @{ Scopes = @($scopes); NoWelcome = $true }
if ($TenantId) { $cp.TenantId = $TenantId }
Connect-MgGraph @cp
}
# ---- Read current state -----------------------------------------------------------------------------------
$sel = 'id,displayName,userPrincipalName,mail,userType,accountEnabled,onPremisesSyncEnabled,assignedLicenses,licenseAssignmentStates'
try {
$user = Invoke-Graph -Uri ("$Graph/users/{0}?`$select={1}" -f [uri]::EscapeDataString($UserPrincipalName), $sel)
} catch {
throw "Cannot read user ${UserPrincipalName}: $($_.Exception.Message)"
}
$userId = [string]$user['id']
$isSynced = [bool](Get-Value $user 'onPremisesSyncEnabled')
$skuNames = @{}; $skuPlans = @{}
try {
foreach ($s in (Get-GraphCollection "$Graph/subscribedSkus")) {
$skuNames[[string]$s['skuId']] = [string]$s['skuPartNumber']
$skuPlans[[string]$s['skuId']] = @(foreach ($p in @(Get-Value $s 'servicePlans')) { if ($p) { [pscustomobject]@{ Id = [string]$p['servicePlanId']; Name = [string]$p['servicePlanName'] } } })
}
} catch { Write-Warning "subscribedSkus not readable ($($_.Exception.Message)); every licence is treated as an Exchange licence" }
$licenses = @(Get-LicenseInfo (Get-Value $user 'licenseAssignmentStates') $skuNames $skuPlans)
$directSkus = @($licenses | Where-Object { -not $_.AssignedByGroup } | Select-Object -ExpandProperty SkuId -Unique)
$memberOf = @(Get-GraphCollection ("$Graph/users/{0}/memberOf" -f $userId))
$groups = @($memberOf | Where-Object { $_['@odata.type'] -eq '#microsoft.graph.group' } | ForEach-Object {
$gt = @(Get-Value $_ 'groupTypes')
[pscustomobject]@{
Id = [string]$_['id']; DisplayName = [string](Get-Value $_ 'displayName'); Mail = [string](Get-Value $_ 'mail')
IsUnified = ($gt -contains 'Unified'); IsDynamic = ($gt -contains 'DynamicMembership')
MailEnabled = [bool](Get-Value $_ 'mailEnabled'); SecurityEnabled = [bool](Get-Value $_ 'securityEnabled')
OnPremSynced = [bool](Get-Value $_ 'onPremisesSyncEnabled'); RoleAssignable = [bool](Get-Value $_ 'isAssignableToRole')
}
})
$roles = @($memberOf | Where-Object { $_['@odata.type'] -eq '#microsoft.graph.directoryRole' })
$forwardTarget = $null; $forwardNote = ''
if ($ForwardTo) { $forwardTarget = $ForwardTo }
elseif ($ForwardToManager) {
try {
$mgr = Invoke-Graph -Uri ("$Graph/users/{0}/manager?`$select=id,displayName,userPrincipalName,mail" -f $userId)
$forwardTarget = if (Get-Value $mgr 'mail') { [string]$mgr['mail'] } else { [string](Get-Value $mgr 'userPrincipalName') }
} catch {
$forwardNote = if ($_.Exception.Message -match 'NotFound|404|does not exist') { 'No manager is set on the user in Entra ID; use -ForwardTo' }
else { "Could not read the manager ($($_.Exception.Message)); use -ForwardTo" }
}
}
# ---- Exchange Online --------------------------------------------------------------------------------------
$mailbox = $null; $mailboxBytes = $null; $orgHolds = $null; $exoConnected = $false
if ($needExchange -or ($groups | Where-Object { $_.MailEnabled -and -not $_.IsUnified })) {
if (Get-Module -ListAvailable -Name ExchangeOnlineManagement) {
Import-Module ExchangeOnlineManagement
$existing = @(Get-ConnectionInformation -ErrorAction SilentlyContinue | Where-Object { $_.State -eq 'Connected' -and $_.Name -like 'ExchangeOnline_*' })
if ($existing) { $exoConnected = $true }
elseif ($needExchange) {
$c = @{ ShowBanner = $false }
if ($PSCmdlet.ParameterSetName -eq 'AppOnly') {
if (-not $Organization) { throw 'App-only Exchange steps need -Organization (the tenant''s initial domain, e.g. contoso.onmicrosoft.com).' }
$c.AppId = $ClientId; $c.CertificateThumbprint = $CertificateThumbprint; $c.Organization = $Organization
} elseif ($ExchangeAdminUpn) { $c.UserPrincipalName = $ExchangeAdminUpn }
Connect-ExchangeOnline @c
$exoConnected = $true
}
}
if ($exoConnected) {
try {
$mailbox = Get-EXOMailbox -Identity $UserPrincipalName -Properties LitigationHoldEnabled, InPlaceHolds, ComplianceTagHoldApplied, DelayHoldApplied, DelayReleaseHoldApplied, ArchiveStatus, ArchiveGuid, RecipientTypeDetails, ForwardingAddress, ForwardingSmtpAddress, DeliverToMailboxAndForward -ErrorAction Stop
$stats = Get-EXOMailboxStatistics -Identity $UserPrincipalName -ErrorAction Stop
if ("$($stats.TotalItemSize)" -match '\(([\d,\.]+) bytes\)') { $mailboxBytes = [int64]($Matches[1] -replace '[,\.]', '') }
} catch {
Write-Warning "Could not read the mailbox: $($_.Exception.Message)"
}
# Organisation-wide retention policies (not listed on the mailbox). Unreadable = unknown = licences kept.
try { $orgHolds = @(@((Get-OrganizationConfig -ErrorAction Stop).InPlaceHolds) | Where-Object { $_ } | ForEach-Object { [string]$_ }) }
catch { Write-Warning "Could not read organisation-wide holds: $($_.Exception.Message)" }
}
}
# ---- Snapshot -----------------------------------------------------------------------------------------------
$snapshot = [ordered]@{
TakenAt = (Get-Date).ToString('o'); UserPrincipalName = $UserPrincipalName; Id = $userId
DisplayName = Get-Value $user 'displayName'; AccountEnabled = Get-Value $user 'accountEnabled'; OnPremisesSynced = $isSynced
Licenses = $licenses; Groups = $groups; DirectoryRoles = @($roles | ForEach-Object { [string](Get-Value $_ 'displayName') })
MailboxType = $(if ($mailbox) { [string]$mailbox.RecipientTypeDetails } else { $null })
MailboxSizeBytes = $mailboxBytes; LitigationHold = $(if ($mailbox) { [bool]$mailbox.LitigationHoldEnabled } else { $null })
InPlaceHolds = @(@(Get-MailboxValue $mailbox 'InPlaceHolds') | Where-Object { $_ } | ForEach-Object { [string]$_ })
ArchiveStatus = [string](Get-MailboxValue $mailbox 'ArchiveStatus'); OrganizationHolds = $orgHolds
ForwardingAddress = $(if ($mailbox) { [string]$mailbox.ForwardingAddress } else { $null })
ForwardingSmtpAddress = $(if ($mailbox) { [string]$mailbox.ForwardingSmtpAddress } else { $null })
DeliverToMailboxAndForward = $(if ($mailbox) { [bool]$mailbox.DeliverToMailboxAndForward } else { $null })
PlannedForwardTarget = $forwardTarget
}
$snapshot | ConvertTo-Json -Depth 5 | Out-File -FilePath $SnapshotJson -Encoding UTF8
Write-Status "Snapshot written: $SnapshotJson"
Write-Status ("User: {0} ({1}), enabled={2}, synced from AD={3}" -f (Get-Value $user 'displayName'), $UserPrincipalName, (Get-Value $user 'accountEnabled'), $isSynced)
Write-Status ("Licences: {0} direct, {1} via group. Groups: {2}. Admin roles: {3}." -f $directSkus.Count, @($licenses | Where-Object { $_.AssignedByGroup }).Count, $groups.Count, $roles.Count)
if ($roles.Count) { Write-Warning ("User holds directory role(s): {0}. Remove them separately (and check PIM eligible assignments)." -f (($roles | ForEach-Object { Get-Value $_ 'displayName' }) -join ', ')) }
if (-not $Apply) { Write-Status 'PLAN ONLY - nothing will be changed. Add -Apply to run these steps (use -WhatIf with -Apply for a dry run of the apply path).' }
function Invoke-Step {
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
param([string]$Step, [string]$Target, [string]$Action, [scriptblock]$Do)
if (-not $Apply) { Add-LogEntry $Step $Target $Action 'Planned'; return $true }
if (-not $PSCmdlet.ShouldProcess("$Target", $Action)) { Add-LogEntry $Step $Target $Action 'NotRun (WhatIf/declined)'; return $false }
try { $null = & $Do; Add-LogEntry $Step $Target $Action 'Done'; return $true }
catch { Add-LogEntry $Step $Target $Action 'Failed' $_.Exception.Message; return $false }
}
try {
# 2. Block sign-in
if ($SkipBlockSignIn) { Add-LogEntry 'BlockSignIn' $UserPrincipalName 'Block sign-in' 'Skipped' '-SkipBlockSignIn' }
elseif ($isSynced) { Add-LogEntry 'BlockSignIn' $UserPrincipalName 'Block sign-in' 'Skipped' 'Synced from on-premises AD: disable the account in AD' }
elseif ((Get-Value $user 'accountEnabled') -eq $false) { Add-LogEntry 'BlockSignIn' $UserPrincipalName 'Block sign-in' 'Skipped' 'Already blocked' }
else {
[void](Invoke-Step 'BlockSignIn' $UserPrincipalName 'Block sign-in (accountEnabled=false)' {
Invoke-Graph -Method PATCH -Uri "$Graph/users/$userId" -Body @{ accountEnabled = $false } | Out-Null })
}
# 3. Revoke sessions
if ($SkipRevokeSessions) { Add-LogEntry 'RevokeSessions' $UserPrincipalName 'Revoke sign-in sessions' 'Skipped' '-SkipRevokeSessions' }
else {
[void](Invoke-Step 'RevokeSessions' $UserPrincipalName 'Revoke sign-in sessions' {
Invoke-Graph -Method POST -Uri "$Graph/users/$userId/revokeSignInSessions" | Out-Null })
}
# 4. Convert mailbox to shared
$converted = $false
if ($ConvertToShared) {
if (-not $mailbox) { Add-LogEntry 'ConvertMailbox' $UserPrincipalName 'Convert to shared mailbox' 'Skipped' 'No mailbox found or Exchange Online not connected' }
elseif ([string]$mailbox.RecipientTypeDetails -eq 'SharedMailbox') { $converted = $true; Add-LogEntry 'ConvertMailbox' $UserPrincipalName 'Convert to shared mailbox' 'Skipped' 'Already a shared mailbox' }
elseif ($isSynced) { Add-LogEntry 'ConvertMailbox' $UserPrincipalName 'Convert to shared mailbox' 'Manual' 'User is synced from on-premises AD: in an Exchange hybrid convert the remote mailbox on-premises so directory sync does not turn it back into a user mailbox' }
elseif ($directSkus.Count -eq 0 -and @($licenses).Count -eq 0) { Add-LogEntry 'ConvertMailbox' $UserPrincipalName 'Convert to shared mailbox' 'Skipped' 'User has no licence; Microsoft requires a licence on the mailbox to convert it' }
else {
$converted = Invoke-Step 'ConvertMailbox' $UserPrincipalName 'Convert to shared mailbox (Set-Mailbox -Type Shared)' {
Set-Mailbox -Identity $UserPrincipalName -Type Shared -Confirm:$false -ErrorAction Stop }
if ($converted -and $Apply) {
# Re-read the recipient: only a confirmed shared mailbox lets licences go.
try { $converted = ([string](Get-EXOMailbox -Identity $UserPrincipalName -Properties RecipientTypeDetails -ErrorAction Stop).RecipientTypeDetails -eq 'SharedMailbox') }
catch { $converted = $false }
if (-not $converted) { Add-LogEntry 'ConvertMailbox' $UserPrincipalName 'Verify shared mailbox' 'Warning' 'Could not confirm the mailbox is now shared; licences and licence groups are kept' }
}
}
}
# 5. Auto-reply
if ($AutoReplyMessage) {
if (-not $mailbox) { Add-LogEntry 'AutoReply' $UserPrincipalName 'Set automatic replies' 'Skipped' 'No mailbox found or Exchange Online not connected' }
else {
[void](Invoke-Step 'AutoReply' $UserPrincipalName "Set automatic replies (external audience: $ExternalAudience)" {
Set-MailboxAutoReplyConfiguration -Identity $UserPrincipalName -AutoReplyState Enabled -InternalMessage $AutoReplyMessage -ExternalMessage $AutoReplyMessage -ExternalAudience $ExternalAudience -Confirm:$false -ErrorAction Stop })
}
}
# 5b. Forwarding
if ($ForwardToManager -or $ForwardTo) {
if (-not $forwardTarget) { Add-LogEntry 'Forwarding' $UserPrincipalName 'Forward mail' 'Skipped' $forwardNote }
elseif (-not $mailbox) { Add-LogEntry 'Forwarding' $UserPrincipalName 'Forward mail' 'Skipped' 'No mailbox found or Exchange Online not connected' }
else {
if ([string]$mailbox.ForwardingSmtpAddress) {
Add-LogEntry 'Forwarding' $UserPrincipalName 'Existing forward' 'Warning' ("ForwardingSmtpAddress is already set to {0}; check it, it can override the new forward" -f $mailbox.ForwardingSmtpAddress)
}
[void](Invoke-Step 'Forwarding' $UserPrincipalName "Forward new mail to $forwardTarget (keep a copy)" {
Set-Mailbox -Identity $UserPrincipalName -ForwardingAddress $forwardTarget -DeliverToMailboxAndForward $true -Confirm:$false -ErrorAction Stop })
}
}
# 5c. Licence preservation plan: decided here, before any group membership or licence change
if ($Apply) { $currentLicenses = Read-LicenseState } else { $currentLicenses = $licenses }
$mbxReasons = @(Get-MailboxLicenseReason $mailbox $mailboxBytes $orgHolds ([bool]$converted) $SharedLimitBytes)
$licensePlan = Get-LicensePreservationPlan $licenses $currentLicenses $mbxReasons ([bool]$ForceLicenseRemoval) ([bool]$SkipLicenseRemoval)
foreach ($t in $licensePlan.Reasons) { Add-LogEntry 'LicensePlan' $UserPrincipalName 'Mailbox needs a licence' 'Warning' $t }
if ($licensePlan.ProtectedGroupIds.Count) {
Add-LogEntry 'LicensePlan' $UserPrincipalName 'Keep licence groups' 'Info' ("{0} group(s) kept: {1}" -f $licensePlan.ProtectedGroupIds.Count, $licensePlan.ProtectReason)
} elseif ($licensePlan.ReplacementVerified) {
Add-LogEntry 'LicensePlan' $UserPrincipalName 'Keep licence groups' 'Info' 'Active direct licences already give the Exchange plans of the licence groups; groups can be left, direct licences are kept'
}
# 6. Groups
foreach ($g in $groups) {
$name = if ($g.DisplayName) { $g.DisplayName } else { $g.Id }
if ($SkipGroupRemoval) { Add-LogEntry 'Groups' $name 'Remove membership' 'Skipped' '-SkipGroupRemoval'; continue }
if ($KeepGroup -and ($KeepGroup -contains $g.Id -or $KeepGroup -contains $g.DisplayName)) { Add-LogEntry 'Groups' $name 'Remove membership' 'Skipped' 'In -KeepGroup'; continue }
if ($licensePlan.ProtectedGroupIds -contains $g.Id) { Add-LogEntry 'Groups' $name 'Remove membership' 'Skipped' $licensePlan.ProtectReason; continue }
if ($g.IsDynamic) { Add-LogEntry 'Groups' $name 'Remove membership' 'Manual' 'Dynamic group: membership follows its rule'; continue }
if ($g.OnPremSynced) { Add-LogEntry 'Groups' $name 'Remove membership' 'Manual' 'Group is synced from on-premises AD: remove the member in AD'; continue }
if ($g.RoleAssignable) { Add-LogEntry 'Groups' $name 'Remove membership' 'Manual' 'Role-assignable group: needs Privileged Role Administrator'; continue }
if ($g.MailEnabled -and -not $g.IsUnified) {
if (-not $exoConnected) { Add-LogEntry 'Groups' $name 'Remove membership' 'Manual' 'Distribution list or mail-enabled security group: needs Exchange Online (run with -ConvertToShared/-AutoReplyMessage or remove in the EAC)'; continue }
$gid = if ($g.Mail) { $g.Mail } else { $g.DisplayName }
[void](Invoke-Step 'Groups' $name 'Remove membership (Remove-DistributionGroupMember)' {
Remove-DistributionGroupMember -Identity $gid -Member $UserPrincipalName -BypassSecurityGroupManagerCheck -Confirm:$false -ErrorAction Stop })
continue
}
# Graph: the /$ref suffix is essential. Without it the DELETE targets the user object itself.
$refUri = ('{0}/groups/{1}/members/{2}/$ref' -f $Graph, $g.Id, $userId)
if ($refUri -notmatch '/members/[0-9a-fA-F-]{36}/\$ref$') { Add-LogEntry 'Groups' $name 'Remove membership' 'Failed' "Refusing to call unexpected URI $refUri"; continue }
[void](Invoke-Step 'Groups' $name 'Remove membership (Graph)' {
Invoke-Graph -Method DELETE -Uri $refUri | Out-Null })
}
# 7. Licences (direct only). Re-read first when the mailbox still needs a licence, to report whether it kept one.
if ($Apply -and $licensePlan.NeedsLicense) {
$after = Read-LicenseState
if ($null -ne $after -and -not @($after | Where-Object { @('Active', 'ActiveWithSolutionUpdate') -contains $_.State -and ($null -eq $_.ExchangePlans -or @($_.ExchangePlans).Count) }).Count) {
Add-LogEntry 'LicensePlan' $UserPrincipalName 'Verify mailbox licence' 'Failed' 'No active Exchange licence is left on a mailbox that needs one; assign one within 30 days'
}
}
if ($SkipLicenseRemoval) { Add-LogEntry 'Licenses' $UserPrincipalName 'Remove direct licences' 'Skipped' '-SkipLicenseRemoval' }
elseif ($directSkus.Count -eq 0) { Add-LogEntry 'Licenses' $UserPrincipalName 'Remove direct licences' 'Skipped' 'No direct licences (group-based licences follow group membership)' }
elseif ($licensePlan.NeedsLicense) { Add-LogEntry 'Licenses' $UserPrincipalName 'Remove direct licences' 'Skipped' 'Mailbox still needs a licence (see LicensePlan)' }
elseif (-not $ForceLicenseRemoval -and -not ($ConvertToShared -and ($converted -or -not $Apply))) {
Add-LogEntry 'Licenses' $UserPrincipalName 'Remove direct licences' 'Skipped' 'Mailbox not converted to shared; removing licences deletes mailbox data after 30 days. Use -ConvertToShared or -ForceLicenseRemoval'
} else {
$names = ($directSkus | ForEach-Object { if ($skuNames.ContainsKey($_)) { $skuNames[$_] } else { $_ } }) -join ', '
[void](Invoke-Step 'Licenses' $UserPrincipalName "Remove direct licences: $names" {
Invoke-Graph -Method POST -Uri "$Graph/users/$userId/assignLicense" -Body @{ addLicenses = @(); removeLicenses = @($directSkus) } | Out-Null })
}
foreach ($l in @($licenses | Where-Object { $_.AssignedByGroup })) {
Add-LogEntry 'Licenses' $(if ($l.SkuPartNumber) { $l.SkuPartNumber } else { $l.SkuId }) 'Group-based licence' 'Info' "Assigned by group $($l.AssignedByGroup); removed when the user leaves that group"
}
} finally {
Write-LogFile
Write-Status "Log written: $LogCsv"
}
$failed = @($log | Where-Object { $_.Result -eq 'Failed' }).Count
$manual = @($log | Where-Object { $_.Result -eq 'Manual' }).Count
Write-Status ("Finished. Failed: {0}. Manual follow-up: {1}." -f $failed, $manual)
34350f40d41c141cff54e00348202a69909dfe3a9883ac283c3c37486d544de7curl -fsSL -o Invoke-M365Offboarding.ps1 https://scr.srvscripts.com/m365-user-offboarding/Invoke-M365Offboarding.ps1 && curl -fsSL https://scr.srvscripts.com/m365-user-offboarding/Invoke-M365Offboarding.ps1.sha256 | sha256sum -cInvoke-WebRequest -Uri 'https://scr.srvscripts.com/m365-user-offboarding/Invoke-M365Offboarding.ps1' -OutFile 'Invoke-M365Offboarding.ps1'; if ((Get-FileHash 'Invoke-M365Offboarding.ps1' -Algorithm SHA256).Hash -eq '34350F40D41C141CFF54E00348202A69909DFE3A9883AC283C3C37486D544DE7') { 'OK: the file is intact' } else { 'MISMATCH: do not run this file' }Copy the whole line. In Windows PowerShell, curl and sha256sum are not the Linux tools, so use the PowerShell line there.Also on GitHub: github.com/srvscripts/scripts
Frequently asked questions
How do I offboard a Microsoft 365 user with PowerShell?
Block sign-in, revoke sessions, convert the mailbox to shared, set an auto-reply or forward, check whether the mailbox still needs a licence, then remove groups and licences. This script runs those steps in order with a snapshot, a log and -WhatIf support.
Should I convert the mailbox to shared before removing the licence?
Yes. Microsoft requires a licensed mailbox for the conversion, and once the licence is removed from a user mailbox the data is kept for 30 days and then deleted.
Does blocking sign-in log the user out immediately?
Not always. Microsoft says blocking can take up to 24 hours; reset the password and revoke sessions for faster effect.
Why are some groups marked Manual?
Dynamic groups follow their membership rule, synced groups must be changed in on-premises AD, and role-assignable groups need Privileged Role Administrator, so the script lists them instead of failing.
What does -WhatIf do here?
With -Apply -WhatIf the script goes through the real code path, prints what each step would change and logs it as NotRun, without changing anything.
Can I undo the offboarding?
Mostly. The snapshot JSON lists the licences, groups and forwarding settings the user had, so you can re-add them. Mailbox data is safe while the mailbox is shared or licensed.
Why did the script keep some groups?
Those groups give the mailbox an Exchange licence it still needs: it is not confirmed as shared, is over 50 GB, is on hold, has an archive, or a fact could not be read. The LicensePlan rows in the log CSV give the reason. With -SkipLicenseRemoval every group that assigns a licence is kept.