Ben Thompson › Shared mailbox delegate readiness

Checking delegate readiness before migrating a shared mailbox to Exchange Online

Before a shared mailbox moves, every person who has access to it needs to be checked against the migration state. Passing that check clears one important prerequisite. It does not prove the mailbox itself has no other migration blockers.

The problem

I worked on a phased move from on-prem Exchange to Microsoft 365 in a large healthcare environment. Thousands of users, hundreds of shared mailboxes, public folders, directory-based licensing. It ran in waves over a long period, which is normal for a migration that size and also the source of the problem.

In a hybrid migration performed in waves, there is a middle period where some people are in the cloud and some are still on-prem. During that period, a shared mailbox is only useful because other accounts have permissions on it. Checking those delegates is one part of its migration readiness, not a replacement for checking the mailbox itself.

The failure mode is quiet. You migrate a shared mailbox because the mailbox itself has no blockers. Nothing errors. The move completes. Then a delegate who is still on-prem finds the mailbox has stopped appearing in Outlook, or can no longer send from it, and you get a ticket that does not obviously point back at last night's migration batch.

What Microsoft actually supports cross-premises

This is worth getting exactly right, because the rule is narrower than people assume and the general advice to "just migrate them together" hides the detail. From Microsoft's Permissions in Exchange hybrid deployments:

PermissionCross-premises in hybrid
Full AccessSupported, Outlook desktop. Not fully supported in Outlook on the web, where it produces a redirect and a credentials prompt
Send on BehalfSupported, but needs the Exchange Hybrid option enabled in Microsoft Entra Connect so the attribute syncs
Send AsNot synchronized automatically. Works in most cases only if you add it manually on both sides
Auto-mappingNot supported
Folder permissionsNot supported

For unsupported cross-premises permissions and capabilities, Microsoft adds:

"Any mailboxes that receive these permissions from another mailbox need to be moved at the same time as the granting mailbox. If a mailbox receives permissions from multiple mailboxes, that mailbox, and all of the mailboxes granting permissions to it, need to be moved at the same time."

The demo uses a conservative scheduling prerequisite: every delegate must already have moved before the shared mailbox passes this check. That is not a proof that every dependency was preserved throughout the migration, and it is not a complete mailbox-readiness test. It answers the narrower question of which mailboxes currently pass the delegate check as waves complete.

The approach: invert the question

The useful move is to split out one question: "does every delegate of this mailbox meet the prerequisite?" Then roll the per-delegate answers up into a per-mailbox result. One unresolved delegate blocks the delegate check. No averaging, no percentage ready, no partial pass.

Two inputs, both of which you already have during a migration:

  • A permissions export: one row per delegate per mailbox, with the permission type, the requested wave, and the business owner included for review.
  • A user migration state export: for each account, whether it is on-prem or in Microsoft 365, its mailbox location, its licensing state, and whether it is enabled.

The permissions export is the grain that matters. It is per relationship, not per mailbox, which is the right shape for evaluating delegate dependencies.

The mechanism

Each permission row is joined to the delegate's migration state, then evaluated against an ordered ladder of block reasons. The order is the interesting part: it is arranged so the reason reported is the most fundamental one, not whichever check happened to run first.

$blockReason = if (-not $foundUser) {
    "Delegated user not found in migration export"
}
elseif ($user.Enabled -ne "True") {
    "Delegated user account is disabled"
}
elseif ($user.MigrationState -ne "O365") {
    "Delegated user is not migrated to O365 yet"
}
elseif ($user.LicenseStatus -ne "Ready") {
    "Delegated user is migrated but licensing still needs review"
}
else {
    ""
}

A delegate who is missing from the export entirely is a data problem, not a migration problem, and saying so is more useful than reporting them as "not migrated". A disabled account is a cleanup question for the business owner before it is a migration question. Only once an account is present and enabled does its migration state mean anything, and only once it has migrated does its licensing matter.

The per-mailbox delegate result is then a strict roll-up:

$blockedRows = @($rows | Where-Object { $_.DelegatePrerequisitesMet -ne $true })
$delegateChecksPassed = $blockedRows.Count -eq 0

And the output is two CSVs rather than one. The mailbox-level file gives a migration lead the delegate result for each mailbox. The permission-level detail file is what you open when someone asks why the check was blocked, because it names the specific person.

What the output looks like

Sanitized sample output from the public demo, showing three cases it intentionally models. The full file has nine columns including the requested wave, the business owner, and the next review action; this is four of them, abridged to fit:

MailboxName            BlockedDelegates  DelegateReadinessStatus  DelegateBlockReasons
cardiology-scheduling  0                 Passed
clinic-referrals       1                 Blocked                  Delegated user is not migrated to O365 yet
legacy-records         2                 Blocked                  Delegated user is migrated but licensing
                                                                  still needs review; Delegated user not
                                                                  found in migration export

cardiology-scheduling passes the delegate check and continues to the remaining mailbox-readiness review. clinic-referrals is blocked because a single delegate is still on-prem. legacy-records models two conditions that waiting will not fix: a duplicate license path on one account and a delegate missing from the user export.

That third case is the main argument for keeping reasons in the report. A delegate check that will not clear by itself needs a person to investigate, ideally before the requested wave.

Why it is stricter than Microsoft requires

This check blocks on any unready delegate regardless of permission type. That is stricter than Microsoft's matrix requires, and it is a choice rather than an oversight.

A more precise implementation would let a Full Access delegate through while holding a Send As one, since Full Access does work cross-premises. I did not do that, for three reasons.

  • The supported cases carry conditions that are easy to get wrong in a large environment: Full Access wants the Outlook desktop client, Send on Behalf wants a specific Entra Connect configuration, and auto-mapping does not work cross-premises at all. "Supported" and "behaves the way the user expects" are not the same sentence.
  • The cost of the two errors is not symmetric. Holding a mailbox for one wave costs a scheduling conversation. Breaking access to a clinical shared mailbox costs a ticket, a workaround, and trust in the migration.
  • A rule a migration lead can hold in their head gets followed. "Every delegate has moved" survives being explained in a status meeting. A per-permission-type matrix does not.

If you are running a smaller migration where a wave of delay is expensive, splitting the verdict by permission type is a reasonable change. The block ladder is where you would make it.

Cases the demo intentionally models

The demo checks for a few input conditions that should be separated from normal migration progress. These are modeled cases, not a list of incidents from the real migration.

  • Delegates who were not in the user export. Permissions can outlive accounts, but a missing row can also mean an incomplete export or a matching problem. The report keeps it visible for investigation.
  • Disabled accounts still holding permissions. Worth surfacing to the business owner as a cleanup item rather than silently ignoring.
  • Duplicate license paths. An account can be migrated and still not be in a good state. Treating "migrated" as the finish line hides that.

None of those prove a mailbox is ready to move. They make the delegate prerequisite clear enough for someone to review before the migration wave.

Verification and safety

The properties that made this useful as a repeated check during a live migration:

  • It reads and reports. It does not act. Nothing in it moves a mailbox or changes a permission. The output is a report for a person to review, and it is only as current as the source exports.
  • Required columns are validated before any logic runs. The import throws if the CSV is missing a column it depends on, rather than quietly evaluating null fields and producing a confident wrong answer.
  • Every blocked result carries its reason. A status with no explanation gets argued with. A status naming the delegate gets acted on.
  • It is re-run, not maintained. Readiness changes as waves complete, so the report is regenerated from current exports rather than updated by hand.

The public demo version has a tests/Run-DemoCheck.ps1 that proves the script still runs end to end against the sample data, and GitHub Actions runs it on changes.

The code

This is a sanitized demo built from the real work. It uses fake data, local files, and simulated actions instead of touching Exchange, Active Directory, or Microsoft 365. The workflow shape is the part worth reading.

If you are planning this now

The short version, whatever tooling you use:

  1. Export shared mailbox permissions at the delegate grain, not the mailbox grain. One row per person per mailbox.
  2. Join it to current migration state and treat any delegate you cannot resolve as a blocker, not as a blank.
  3. Roll up strictly. One unresolved delegate blocks the delegate check.
  4. Separate "will unblock when the wave runs" from "needs a human". They look the same in a status column and they are not the same problem.
  5. Regenerate after every wave. A delegate-readiness report is only as current as its source exports.