WT-O365xO365User manual
All manuals
Utility · Microsoft 365 migration

WT-O365xO365

WT-O365xO365 moves mailbox content from one Microsoft 365 tenant to another: mail, In-Place Archive, calendars, contacts, tasks, inbox rules, auto-replies and OneDrive. This manual is for the administrator running the migration. It starts with a short click-by-click quick start, then covers permissions, every setting, the domain cutover runbook, verification and the companion PowerShell utilities.

Version Current releaseUpdated October 2026Windows · Microsoft Graph

01Quick start

This is the short version: what to click and in what order. It skips the reasons and the edge cases; the rest of this manual covers those.

Before you start

You need:

  • Admin access to both tenants.
  • An app registration in each tenant, with permissions granted (see Prerequisites and permissions). If something is missing, the app tells you: click Connect and read the log.
  • A disk with room for the whole mailbox. Budget roughly 300 KB per message, so 100,000 messages needs about 30 GB.
  • Time. A large mailbox takes a day or more, and no setting makes that faster, because Microsoft limits how fast data can be uploaded.
The one setting that matters

Leave Export native FTS stream ticked. It preserves read/unread state and categories, and it stops the calendar from emailing an invitation to every attendee of every migrated meeting.

The order of operations

Do things in this order. Skipping ahead causes the problems.

  1. Weeks ahead: create users in the new tenant and check what is blocking the domain move.
  2. Days ahead: migrate the bulk of the mail while everyone keeps working.
  3. Cutover day: final top-up, move the domain, switch DNS.
  4. After: OneDrive, and a last check.

Part 1: weeks ahead

1.1 Create the users

In the new tenant, create every user, shared mailbox and group. Use the @something.onmicrosoft.com address for now: the real domain still belongs to the old tenant and cannot be in two places.

Licences

Assign licences. An unlicensed mailbox cannot receive mail, and this is the single most commonly forgotten step.

1.2 Find out what will block the domain move

Run this against the old tenant. It changes nothing. Sign in as a Global Admin when prompted.

.\Test-DomainRemovalReadiness.ps1 -Domain contoso.com
Tip

Easier way to get the command: open the app, go to the Utilities tab, fill in the domain and click Copy beside the script. It builds the whole command from your saved profiles so you never type a GUID. Every command in this quick start can be copied from there.

It prints a list of things holding onto the domain: user sign-in names, groups, old deleted accounts. Fix these now, not on cutover day. Each one is a five-minute job in advance and a panic in the middle of a window. Re-run it until the list is empty, or nearly.

If it warns about scan gaps, part of the check did not run. Run the following and try again:

Import-Module ExchangeOnlineManagement
Connect-ExchangeOnline

Part 2: set up the app

2.1 Config tab: the two tenants

  1. Open WT-O365xO365. Fill in the Source boxes (old tenant): Tenant ID, Client ID, Client Secret, and the mailbox you are migrating.
  2. Click Connect. The label beside the button should turn green and say Connected. If it says Failed, look at the Log tab; it names the problem.
  3. Do the same for Destination (new tenant).
  4. Click Save on each profile and give them names you will recognise, such as old and new. From now on you just pick the profile from the dropdown.
Note

Failed on the destination with "requested user is invalid" almost always means a typo in the mailbox address.

2.2 Options tab: the settings that matter

Most of this panel can be left alone. These are the ones to look at:

SettingSet it toWhy
Export native FTS streamTickedKeeps unread state and categories. Stops calendar invitations being sent.
Organize exported mail into folder treeTickedStops 100,000 files landing in one folder.
Delta passUntickedFor now. Tick it on cutover day only.
Preserve original timestampsTickedMail keeps its real dates.
Server-side dedup checkTickedStops duplicates if you re-run.
Keep this computer awakeTickedStops the PC sleeping mid-migration.

Staging Folder: point this at a disk with room, for example E:\Migration. This is where mail is parked between the two tenants.

2.3 Migration Scope: what to migrate

  • Mail: yes.
  • Calendar, Contacts, Tasks: yes.
  • Inbox rules, Auto-reply (OOF): yes.
  • Include In-Place Archive: only if the user has one.
  • OneDrive: leave unticked for now. You do this last, after cutover.

2.4 Save the settings

At the top of the Options tab there is an options-profile dropdown with a Save button. Save with a name like standard.

Do this

Settings do not persist by themselves between sessions. FTS being off is what causes the calendar to email everybody. Save the options profile once and load it every time.

Part 3: days ahead, migrate the bulk

Everyone keeps working normally through this. Nothing they do is lost.

3.0 Do it in three passes, not one

For anything bigger than a handful of mailboxes, split the work. Move the large, cold, never-changing mail weeks early, when nobody cares how long it takes, so the cutover window only has to catch up on recent activity.

PassWhenDate filterWhat it moves
1Weeks aheadTo = one month ago, From blankThe old bulk. Slow, and it does not matter.
2Days aheadFilter offThe month you deliberately skipped.
3CutoverFilter off, Delta pass onOnly what changed since pass 2. Minutes.

Passes 2 and 3 are fast because everything already staged is skipped.

Get the date filter the right way round

It is a range, not a cutoff. For "a month old and older", set the To date and leave From empty. Setting From instead migrates the newest mail, which is exactly backwards.

Do not expect pass 1 to be quick just because it is old mail. Old mail is where the attachments are, so pass 1 is usually most of the total gigabytes and, because the limit is on bandwidth rather than message count, most of the total time. That is the point: it is time spent when nobody is waiting.

3.0a While you are at it, measure concurrency

Near cutover you will want several mailboxes running at once. Whether that helps depends on something worth measuring now, during pass 1, when a wrong answer costs nothing. Microsoft limits upload speed per mailbox. If that is the only limit, four mailboxes at once go four times as fast. If there is also a tenant-wide limit, they share one allowance and running four gains nothing.

At the end of each mailbox the log prints:

pair throughput: 4,771 items in 62.3 min = 76.6 items/min (Concurrent=4)

Run two similarly sized mailboxes with Concurrent mailboxes set to 1, then two more with it set to 4, and compare that figure:

  • About the same: the limits are independent. Use 4 at cutover.
  • Roughly quartered: you have hit a tenant ceiling. Leave it at 1 or 2; more workers just divide the same pipe.

Each running mailbox needs its own staging space (roughly 30–40 GB), so four at once wants around 160 GB free. Compare similarly sized mailboxes, or the numbers mean nothing.

3.1 Start it

Load your source profile, destination profile and options profile from the three dropdowns. Click Export + Import.

3.2 Check the first minute

Look for these lines in the log:

Export format : FTS (native MAPI stream, batched)
Manifest entries already present: 0
Sanity probe: PASS
  • "FTS": good. If it says "MIME", stop and tick the FTS box.
  • "already present: 0": good, the staging folder is clean.

Then leave it. A big mailbox runs overnight, sometimes longer.

3.3 What normal looks like

Throttled HTTP 429 (importUrl) in /Inbox. Waiting 93s (attempt 1/5)...

That is fine. Microsoft limits upload speed and the app waits its turn. It is not an error and needs no action. What you want at the end of each folder:

folder summary: OK 3188 / Fail 0 / Skip 4775 (saw 7963 of 7963)

The numbers either side of "of" after "saw" matching is the important bit.

3.4 What is not normal

!! /Inbox: enumerated 99,259 of 114,834 items -- 15,575 NOT SEEN.

Some mail was missed. Run Export again: everything already done is skipped, and it picks up the rest. Repeat until that line stops appearing.

3.5 Check it worked

When it finishes, go to the Utilities tab, click Copy beside Compare-FolderCounts.ps1 and paste it into PowerShell. Or type it by hand:

.\Compare-FolderCounts.ps1 `
  -SrcTenantId <old-tenant-id> -SrcClientId <client-id> -SrcSecret '<secret>' `
  -DstTenantId <new-tenant-id> -DstClientId <client-id> -DstSecret '<secret>' `
  -SrcMailbox user@contoso.com -DstMailbox user@fabrikam.com

Look at Short on dest. A handful of items short is normal: that is mail that arrived while you were copying. Hundreds short means run the export again.

Part 4: cutover day

This is the part where mail stops for a while. Do it out of hours.

4.0 First, lower your DNS TTLs (a day early)

TTL is "how long the internet is allowed to remember this answer". While it is high, changing a DNS record does nothing for hours. Lower it first and the switch takes effect in minutes. TTL is a per-record field, in seconds, wherever your domain's DNS lives (GoDaddy, Cloudflare, Namecheap, Route 53, or Microsoft 365 if it manages the zone). Set these to 300:

Record typeName / hostWhat it does
MX@ or blankWhere your mail is delivered. The important one.
CNAMEautodiscoverHow Outlook finds the mailbox.
TXT@ or blankThe one starting v=spf1, not any others.
CNAMEselector1._domainkeyDKIM, if you use it.
CNAMEselector2._domainkeyDKIM, if you use it.

If a record is not in your zone, skip it. MX, autodiscover and SPF are the three that always matter. See what you have now:

$domain = 'contoso.com'
Resolve-DnsName $domain                -Type MX    -Server 8.8.8.8
Resolve-DnsName "autodiscover.$domain" -Type CNAME -Server 8.8.8.8
Resolve-DnsName $domain                -Type TXT   -Server 8.8.8.8

The TTL column is how many seconds you must wait after lowering before it takes effect. 3600 is an hour; 86400 is a whole day, so lower it the day before, not the morning of.

Save the old values

Copy that output into a file before you change anything. If you have to undo the cutover, those are the values you put back. Set TTLs back to normal (3600 is typical) a few days after cutover.

4.1 Final top-up

In the app, tick Delta pass, then click Export + Import. This grabs only what arrived since your last run: minutes, not hours. Untick it again afterwards so you do not forget it is on.

4.2 Point the old mailboxes at the new ones

So mail arriving during the switch still lands somewhere:

Set-Mailbox -Identity user@contoso.com `
  -ForwardingSmtpAddress 'user@fabrikam.onmicrosoft.com' `
  -DeliverToMailboxAndForward $false
Caution

Forward to the onmicrosoft.com address. The real domain does not work in the new tenant yet.

4.3 Take the domain off the old tenant

Change everyone's sign-in name and email addresses in the old tenant to @something.onmicrosoft.com. This is the tedious bit, and it is why you ran the readiness check weeks ago. Then go to Microsoft 365 admin center → Settings → Domains and remove the domain. If it refuses, it names what is still holding on. Fix and retry.

4.4 Put it on the new tenant

Admin center → Settings → Domains → Add domain. Add the TXT record it gives you and verify. Then set everyone's proper email address on their new mailbox.

4.5 Switch DNS

  • MX → the new tenant.
  • Autodiscover → autodiscover.outlook.com.
  • SPF → the new tenant's include.
  • Turn on DKIM in the new tenant.

Send yourself an email from outside. If it arrives, you are through the hard part.

Part 5: after cutover

5.1 Tell users what to expect

Three things will happen to them, and all three generate calls if unannounced:

  • Outlook needs a new profile. Not a repair: a new one.
  • Phones need the account removed and re-added.
  • OneDrive files are not there yet. They arrive in the next step.

5.2 OneDrive

Wait until sign-in names have settled. After a rename, OneDrive URLs change behind the scenes and it can take a few hours. Then in the app:

  1. Tick OneDrive (files + folders) in Migration Scope.
  2. Untick everything else.
  3. In Import into subfolder, type something like Migrated from contoso.com.
  4. Click Export + Import.
Use the subfolder

Users are working in the new tenant now. Importing to the top level can overwrite a file they created this morning; their version goes into version history where they will never look for it. A subfolder makes that impossible, and they can move things where they want afterwards.

5.3 Final check

Run Compare-FolderCounts.ps1 once more. Then spot-check one user in Outlook: their folders, some old mail, their calendar. Leave the old tenant alone for 30–60 days. It is your safety net.

If something goes wrong

What you seeWhat to do
Connect says FailedLook at the Log tab. It names the problem, usually a typo or a missing permission.
429 Too Many Requests everywhereNormal. Ignore it.
NOT SEEN in the logRun Export again. Repeat until it stops.
Hundreds of items shortRun Export again, then Import.
Export format: MIMEThe FTS box is unticked. Tick it and re-run.
People got meeting invitationsFTS was off for the calendar. Nothing to do now, but tick it before the next mailbox.
Migration stopped overnightThe PC slept or restarted. Run it again: nothing is lost, it picks up where it stopped.
Domain will not removeIt tells you what is holding it. Fix those and retry.
Re-running is safe

Every run skips what it already did. If in doubt, run it again.

The six things that actually matter

  1. Leave FTS ticked. Everything else is a preference; this one is not.
  2. Save your profiles, both tenants and the options. Settings do not persist otherwise.
  3. Lower DNS TTLs a day before cutover: MX, autodiscover and SPF at minimum. You cannot fix this on the day; a 24-hour TTL means 24 hours.
  4. Move the old mail weeks early. Three passes, not one. The cutover window should only ever be catching up on a few days of activity.
  5. Run the domain readiness check weeks ahead, not on cutover day.
  6. Check the counts at the end. Zero failures does not mean everything arrived.

02What it does and how it works

WT-O365xO365 is a single-file PowerShell WinForms application that migrates mailbox content from one Microsoft 365 tenant to another through Microsoft Graph and the Graph Mailbox Import/Export API. It migrates:

  • Mail (Inbox, Sent Items, custom folders and so on), including Notes that propagate via mail flow (migrated as messages).
  • In-Place Archive mail, when enabled on the source mailbox.
  • Calendar events.
  • Contacts, including photos.
  • Tasks (Microsoft To Do).
  • Inbox rules, with folder references retargeted to the destination.
  • Auto-reply (OOF) settings: the current configuration.
  • OneDrive files and folders, with original timestamps preserved.

Single-mailbox and batch modes

  • Single mailbox mode: one source UPN, one destination UPN, optional starting folder. Useful for testing, partial migrations and one-off moves.
  • Batch mode: many mailbox pairs in a grid. Each pair migrates the entire mailbox (or a per-row folder scope) to the destination, optionally with up to four mailboxes running in parallel.

Batch mode is active whenever the Batch Mailboxes grid has at least one row.

FTS and MIME: the two export formats

FTS (Fast Transfer Stream) is Microsoft's native binary format for Exchange mailbox items: every MAPI property, not just the parts that survive conversion to email. MIME (.eml) is the portable internet mail format, but it has nowhere to record read/unread state, categories or follow-up flags, and Graph accepts MIME only on an endpoint that creates drafts, so each MIME import must be patched afterwards to un-draft it.

With Export native FTS stream ticked (the default), each item's native stream is exported as a .fts file and written directly into the destination store: never a draft, nothing to repair, no notifications sent.

MIME pathFTS path
Export calls1 per message1 per 20 messages (batched)
Import calls72
Uploads per message21
Read/unread, categories, flagsLost, partially reconstructedPreserved

Because import is capped by Microsoft's per-mailbox upload limit (see Performance), FTS roughly doubles import speed; the main reason to use it is fidelity. The trade-off is portability: no mail client can open an .fts file. Use FTS for Exchange-to-Exchange migrations, and MIME only when the destination is not Microsoft 365.

Fallback

FTS export needs extra permissions on the source (see Source app permissions). If they are missing, the export logs a warning and falls back to MIME rather than failing.

Fresh staging folder

.fts and .eml are not interchangeable. Changing the FTS setting requires a fresh staging folder; otherwise formats are mixed in one manifest.

Calendar migration and meeting invitations

The single most damaging thing a calendar migration can do is email people. Creating an event through the Graph calendar API is a create, not an import: when the event has attendees and the destination mailbox becomes the organiser, Exchange sends invitations. Every migrated meeting mails every attendee, including meetings from years ago, and including cancellations for meetings that were already cancelled at the source. Graph offers no way to suppress this on that endpoint.

With Export native FTS stream on, the calendar takes the same path as mail: each event's native stream is written directly into the destination store. The calendar API is never involved, so no invitation logic fires. Attendees, responses, recurrence, organiser identity and attachments arrive verbatim. On a live migration, 193 events with attendees were imported this way with zero notifications sent.

Important

If calendar is in scope and FTS is off, the tool warns you before running, names the consequence and defaults to No. Do not click past this warning on a production mailbox.

Shared and system calendars

Graph's calendar list includes calendars other people have shared into the mailbox. Those are not the mailbox's data, and migrating them would copy someone else's calendar (and, on the non-FTS path, generate invitations for meetings this mailbox never organised). The export filters automatically:

  • Shared / delegated calendars: owner address points at a different mailbox.
  • System calendars such as Birthdays and Holidays: detected as read-only, with name patterns as a fallback.

Skips are logged explicitly:

- skipping calendar 'Jordan Lee': shared from jordan@contoso.com
- skipping calendar 'Birthdays': read-only (system or shared)
Skipped 3 shared/system calendar(s).

The default calendar is never filtered, even if its reported owner does not match the mailbox, because silently dropping the primary calendar would be the worst possible outcome.

How mail is enumerated

A long walk through a folder with ordinary paging is a live cursor, not a snapshot: as mail arrives, is read or moved, pages overlap and items slide past unseen. WT-O365xO365 therefore enumerates with Graph's delta query, which uses a stable server-side snapshot so the walk stays consistent regardless of user activity. Because delta queries cannot filter by date, the date filter is applied client-side; enumeration is metadata-only and cheap, and item bodies are still only fetched for items inside the filter.

Per-folder reconciliation. After each folder, the export compares the number of unique items it saw with the folder's own item count and logs an error if it came up short:

!! /Inbox: enumerated 99,259 of 114,834 items -- 15,575 NOT SEEN. Re-run Export to pick them up.
Zero errors is not proof

A run can report zero failures and be correct about it: nothing failed, because the missing items were never enumerated. Only a count reconciliation surfaces this. If you see a NOT SEEN line, re-run Export; staged items are skipped and the missed ones are picked up.

Shared, room and equipment mailboxes

Shared mailboxes (info@, sales@ and so on) are fully supported on both sides, exactly like user mailboxes; no delegated sign-in is involved. In single mode, enter the shared mailbox UPNs (for example info@contoso.com). Pull Source / Pull Dest label them (Shared); type shared in the filter, click All, then OK to load only shared mailboxes.

  • Tasks usually report 0 items (shared mailboxes have no To Do lists).
  • Auto-reply and inbox rules work as on user mailboxes.
  • OneDrive auto-skips with one warning line; shared mailboxes have no personal OneDrive.
  • Room and Equipment mailboxes are listed as (Room) / (Equipment). Migration works but is rarely useful.

What it does not do

  • Public folders: out of scope.
  • Calendar attachments over 3 MB (non-FTS calendar path): skipped with a warning.
  • Recurring meeting exceptions (non-FTS calendar path): the master and each occurrence export as separate events, so some series may not show the recurrence pattern correctly.
  • Transport rules, DLP, mail flow rules: tenant-level, not user-level. Migrate separately.
  • OOF history: only the current auto-reply configuration.
  • Distribution lists and contact groups: only individual contacts. DLs are tenant-level objects.
  • Inbox rules forwarding to external addresses may import but be blocked by the destination's outbound spam policy.
  • OneDrive sharing and permissions: files migrate but sharing links, invitations and permissions reset; the destination user owns everything and must re-share.
  • OneNote notebooks, OneDrive items shared to the user and OneDrive version history: see OneDrive.
  • Calendars beyond the user's primary calendar (shared or group calendars) and contact folders other than the default Contacts folder are not migrated. Task lists beyond the default and "Flagged Emails" are migrated, but their order in Outlook may differ.

03Prerequisites and permissions

Software and network

  • Windows 10 / 11 or Windows Server 2019 or later, with PowerShell 5.1 minimum (PowerShell 7 is also fine).
  • Internet access to graph.microsoft.com and outlook.office365.com.
  • The ExchangeOnlineManagement PowerShell module (installed automatically on first use of Pull Source / Pull Dest).
  • Local admin rights are not required; the tool runs in the user's profile.

Two app registrations, one per tenant

You need one app registration in the source tenant (read access) and one in the destination tenant (read and write). Each uses the client-credentials flow: Application permissions, no user signed in. App registrations cannot span tenants, so each side is self-contained, has its own secret and can be revoked independently.

Create each in Microsoft Entra admin center → App registrations → New registration:

  1. Name it, for example WT-O365xO365-Source or WT-O365xO365-Destination.
  2. Supported account types: Accounts in this organizational directory only. Leave Redirect URI blank.
  3. After creation, note the Application (client) ID and Directory (tenant) ID.
  4. Certificates & secrets → New client secret, and copy the Value. It is shown only once.
  5. API permissions → Add a permission → Microsoft Graph → Application permissions, and tick the permissions from the tables below.
  6. Click Grant admin consent for {tenant}. Every permission must show a green checkmark.

All permissions are Microsoft Graph Application permissions (not Delegated), admin-consented in their own tenant.

Source app permissions

PermissionNeeded for
Mail.ReadRequired. Primary mailbox export.
User.Read.AllRequired. UPN resolution at the start of every pair, Pre-Flight checks, Pull Source.
MailboxFolder.Read.AllIn-Place Archive folder enumeration, and native FTS export of the primary mailbox.
MailboxItem.Read.AllIn-Place Archive export, and native FTS export of the primary mailbox.
MailboxSettings.ReadInbox rules and auto-reply.
Calendars.ReadCalendar export.
Contacts.ReadContacts and photos export.
Tasks.Read.AllMicrosoft To Do export.
Files.Read.AllOneDrive export.
Graph consent is not enough for FTS

The two Mailbox permissions also need Exchange RBAC role assignments in the source tenant: Application MailboxItem.Read and Application MailboxFolder.Read. The Entra portal shows green checkmarks for the Graph half while the API still returns 403, because the RBAC half is invisible there. Test-FtsExportReadiness.ps1 settles it (see PowerShell utilities).

Destination app permissions

PermissionNeeded for
Mail.ReadWriteRequired. Mail import, reading folders back to verify, Email tab configuration check.
MailboxFolder.ReadWrite.AllRequired. Destination folder discovery and missing-folder creation.
MailboxItem.Read.AllRequired. Part of the MIME import round trip. Also needs RBAC Application MailboxItem.Read.
MailboxItem.ImportExport.AllRequired. Creating import sessions and uploading items. Also needs RBAC Application MailboxItem.ImportExport.
User.Read.AllRequired. UPN resolution, Pre-Flight checks, configuration probes.
MailboxSettings.ReadWriteInbox rules and auto-reply.
Calendars.ReadWriteCalendar import.
Contacts.ReadWriteContacts import.
Tasks.ReadWrite.AllMicrosoft To Do import.
Files.ReadWrite.AllOneDrive import.
Mail.SendOptional email summary at completion. Also needs RBAC Application Mail.Send. Skip both if you do not use that feature.

Exchange RBAC for Applications

The Mailbox Import/Export API endpoints require RBAC for Applications in Exchange Online; granting Application permissions in Entra is not sufficient by itself. This is a one-time setup per app, scoped to a set of mailboxes or to all mailboxes. For the destination app, in Exchange Online PowerShell:

Connect-ExchangeOnline -UserPrincipalName admin@fabrikam.com

# Repeat for each Mailbox-* role you need:
$roles = @(
    'Application Mail.ReadWrite',
    'Application MailboxItem.ImportExport',
    'Application MailboxItem.Read',
    'Application MailboxFolder.ReadWrite',
    'Application Mail.Send'                 # OPTIONAL: only if using the email-completion feature
)
foreach ($role in $roles) {
    New-ManagementRoleAssignment `
        -App <destination-app-enterprise-object-id> `
        -Role $role `
        -RecipientAdministrativeUnitScope $null `
        -CustomResourceScope (Get-ManagementScope -Identity <scope-name>)
}

For FTS export, assign Application MailboxItem.Read and Application MailboxFolder.Read to the source app in the source tenant in the same way. To see what an app already has: Get-ManagementRoleAssignment -App <client-id>.

Propagation

RBAC changes take several minutes to propagate. If you have just granted them, wait ten minutes and re-test rather than concluding they did not work.

If you cannot configure RBAC for Applications, the tool can fall back to a manually supplied Mailbox-API ID per mailbox; see Mbx-API ID.

04Installation and first launch

WT-O365xO365 is a single PowerShell script. Launch it with:

PowerShell.exe -ExecutionPolicy Bypass -File C:\path\to\WT-O365xO365.ps1

On first launch it creates %APPDATA%\WT-O365xO365\ containing:

  • Profiles\: saved tenant credentials, encrypted to the current Windows user.
  • Logs\: per-run CSV logs.

If you previously used the tool under its earlier name, WT-MailBridge, your profiles and logs in %APPDATA%\WT-MailBridge\ are copied to the new location on first launch. The old folder is left in place.

The build stamp (YYMMDD-HHMM) is shown in the title bar. Include it when reporting a problem.

Interface notes

  • Connection status. Each Connect button shows Not tested, Connected HH:MM, Partial - see Log or Failed - see Log. Editing a credential resets it to Not tested. Details go to the Log tab.
  • Keep this computer awake suppresses idle sleep during a run and is released afterwards. It does not override an explicit sleep or lid close. If the log says the power API is unavailable, use powercfg /change standby-timeout-ac 0 and powercfg /change hibernate-timeout-ac 0.
  • Restart-pending warning. If Windows intends to reboot, starting a run warns you first. Use Windows Active Hours to control restart timing.
The on-screen log is the only copy

The activity log is not written to disk; the Logs folder holds per-run CSVs only. Clear log (on the Run tab) discards it, which is why it offers to save first. Save log writes a .txt whose header carries the build version, machine and PowerShell version.

05Profiles and connectivity

Tenant profiles

A tenant profile stores Tenant ID, Client ID, Client Secret and mailbox UPN under a friendly name. The Source Tenant and Destination Tenant sections each have their own profile picker:

  • New → enter values → Save → choose a profile name.
  • Load → select a profile from the dropdown; the fields are populated.
  • Delete → remove the selected profile.

Client secrets are encrypted against the current Windows user. They cannot be opened on another machine or by another user, and there is no plaintext export.

The Options tab has its own options profile (see Quick start, step 2.4). Save one and load it every time; settings do not persist between sessions otherwise.

Testing a connection

Connect acquires a token, verifies the mailbox exists, reads one message, and (destination only) verifies Mailbox-API access. Failures and their fixes are listed under Troubleshooting. The Troubleshoot... button at the top of the window opens a reference of common app-registration errors with copy-pastable PowerShell fixes.

Setup Wizard

The Setup Wizard walks through the app-registration setup. Its permission rows have Check permissions buttons that run a functional probe for each permission. Tab 5. Email Notification covers the optional completion email (see Email summary).

Mbx-API ID

Mail import needs the destination mailbox's internal ID, MBX:<exchange-guid>@<tenant-id>. Normally it is discovered automatically; if the destination app has no access to that endpoint, discovery fails with 403 and mail import is skipped. To fill it in:

  • Single mode: Lookup... next to the field (browser sign-in to the destination). Recommended.
  • Batch, in bulk: Pull Dest or Provision Dest... fill it automatically.
  • Batch, gaps: Lookup IDs resolves every row with a destination UPN but no ID (or only the selected rows).
  • Manually: Get-Mailbox <upn> | Select ExchangeGuid in destination Exchange Online, then enter MBX:<that-guid>@<destination-tenant-id>.
Only MBX: tokens belong here

A UPN typed into this field makes mail import fail while everything else succeeds. The value is saved in the tenant profile, so after clearing it, re-save the profile. See Troubleshooting.

Browser sign-in

Pull Source, Pull Dest, Lookup, Lookup IDs, Provision and Forwarding sign in to Exchange Online in the browser. Use any admin account with the needed role (typically Global Admin or Exchange Admin); it does not have to match the mailboxes being migrated.

06Migration options

These settings are on the Options tab (Migration Options panel). Save them in an options profile.

Migration Scope and source/destination folders

  • Migration Scope boxes: Mail, Calendar, Contacts, Tasks, Inbox rules, Auto-reply, OneDrive (files + folders).
  • Include In-Place Archive: also migrate the source archive mailbox.
  • Include calendar attachments.
  • Skip Folders: comma-separated folder names; entire subtrees with that name are excluded. The default list includes /Conversation History and /Sync Issues.
  • Apply date filter: a From/To range, or an "older than" cutoff. Remember it is a range: for old mail, set To and leave From empty.
  • Staging Folder: where exported items are parked between tenants. Needs room for the whole mailbox, per running mailbox.

Export native FTS stream

Default on, and saved in the options profile. It defaults on because the alternative is dangerous rather than merely slower: with it off, calendar events are re-created through the calendar API and Exchange emails every attendee. See FTS and MIME.

  • Preserves read/unread state, categories and follow-up flags.
  • Roughly doubles import throughput (one upload per message instead of two).
  • Requires MailboxItem.Read.All and MailboxFolder.Read.All plus their RBAC roles on the source app. Falls back to MIME with a warning if they are missing.
  • Requires a fresh staging folder when you change it.

The format is recorded per row in the staging manifest, so a staging folder exported one way always imports the matching way.

Prove it on ten messages first

Before a large run, pick a small folder, export it with the box ticked into an empty staging folder, import it, and confirm read/unread state and categories arrived intact. Compare-MailFidelity.ps1 checks this without disturbing anything.

Organize exported mail into folder tree

Keep it ticked, with either format. Without it, every staged message lands flat in the staging root. A 114,000-item Inbox means 114,000 files in one directory, where NTFS performance degrades sharply and Explorer, antivirus and backup all crawl.

Server-side dedup and timestamps

Server-side dedup check (default on) makes import check which messages already exist in each destination folder and skip them. It catches the case where staging was wiped between runs. Preserve original timestamps keeps mail on its real dates.

Delta pass

Tick Delta pass to run the next batch as a delta. Each pair uses its own last successful sync time (from its state file) as a date floor: source items modified before it are skipped and only newer items are exported. Destination dedup catches anything the date filter overshoots. A pair with no prior state falls back to a full migration.

Delta pass applies to batch mode. In single-mailbox mode, use the date filter instead. Untick it after use.

Concurrent mailboxes

How many mailboxes run in parallel in batch mode. Range 1–4, default 2.

  • 1: sequential, one pair at a time.
  • 2–4: N workers; each takes one pair, runs all its phases in order, then takes the next. Microsoft allows 4 concurrent connections per mailbox and each worker uses one, so 4 is the safe maximum.

The default balances throughput against tenant-wide throttling; for large batches, 2–3 is usually a good fit. Measure before raising it (see Quick start 3.0a). In concurrent mode, worker log lines are tagged with the source UPN, and grid results arrive in completion order.

Items per mailbox

How many item fetches run in parallel within one mailbox's Export phase. Range 1–8, default 4. Roughly 2.5–3× faster than sequential at 4, and 3.5–4× at 8; beyond 8, throttling dominates. It does not affect Import.

The two settings multiply: Concurrent 2 × Items 4 means up to 8 in-flight calls against the source tenant. Pushing both to the maximum (32 in flight) produces throttling. Start with the defaults and raise them only if the log shows no throttling.

Stop mailbox after N fails

Range 0–100, default 0 (disabled). When positive, processing of a mailbox stops after N item failures in a row. A broken mailbox usually fails every item the same way (for example, a missing permission produces a 403 on every item), and without this the tool produces thousands of identical errors before moving on.

  • Per mailbox: only the current mailbox is aborted; other pairs continue.
  • Consecutive: any success resets the counter, across folders.
  • Separate counters for the Export and Import phases.
  • Suggested values: 0 for debugging (see every error), 10–20 for production, 5 for very large batches.
!! Stop-after-consecutive-fails threshold reached (N in a row). Aborting <phase> of <mailbox>. Other pairs in the batch continue.

OneDrive options

  • Size threshold: files above it are skipped and listed. Default 5 GB, set with a spinner in the main window.
  • Import into subfolder: import OneDrive into a named folder instead of the drive root. Use it whenever users are already working in the destination. See OneDrive.

Email summary at completion

Optional. Emails the Outcome Summary report when a run finishes. Tick Email summary at completion and fill in From (a real, licensed mailbox in the destination tenant, such as migration-notify@fabrikam.com) and To (one or more addresses, separated by semicolons or commas). The subject starts with [!] when the run had errors or was cancelled, which makes filter rules easy. A failed send is logged and does not affect the migration.

Two separate gates must both be open on the destination app: the Graph Mail.Send Application permission with admin consent, and the Exchange RBAC role (allow 5–15 minutes to propagate):

Connect-ExchangeOnline
New-ManagementRoleAssignment -App <dest-app-object-id> -Role 'Application Mail.Send'

Setup Wizard tab 5. Email Notification walks through both and generates the command. Check configuration verifies the Graph gate only; Send test email proves both. ErrorAccessDenied means the RBAC role is missing; Authorization_RequestDenied means the Graph permission or consent is missing; ErrorInvalidUser / 404 means the sender mailbox does not exist.

07Running a migration

Single-mailbox workflow

  1. Load or create a Source Tenant profile, enter the source mailbox UPN and click Connect.
  2. Do the same for Destination Tenant.
  3. Optional: click Load Tree on Source to limit the export to one folder (Source Scope). Leave it blank for the whole mailbox.
  4. Optional: click Load Tree on Destination to anchor the import under a folder such as /Migrated (Dest. Anchor). Left blank, source /Inbox/A/B lands at destination /Inbox/A/B.
  5. Optional: set the date filter and Skip Folders.
  6. Tick the Migration Scope boxes, and Include In-Place Archive if the source has one.
  7. Click Export + Import.

The status panel shows live progress and the log shows every API call. When the run finishes, the panel turns green, amber or red, and a tray notification appears if the run took more than a minute.

You can also click Export or Import on their own, for example to re-run just the export after a NOT SEEN warning.

Batch workflow: filling the grid

When the Batch Mailboxes grid has rows, the single-mailbox fields are ignored and each row drives its own pair. Fill it with:

  1. Add Pair: source UPN, destination UPN, optional Mbx-API ID.
  2. Load CSV: a saved grid, header SourceMailbox,DestinationMailbox,MailboxApiId,SourceScope (older three-column files still load).
  3. Pull Source / Pull Dest: pick mailboxes after a browser sign-in. Pull Dest also captures each Mbx-API ID, so it is the recommended way to fill the destination column.

Save CSV keeps the grid. Live columns show State (Pending, Queued, Running, Done, Failed, Cancelled), Last Sync and cumulative OK/Fail; state is reloaded from each pair's state file, so the grid remembers progress across restarts.

Per-row Source Scope

Leave Source Scope (opt) empty for the full mailbox, or enter a path such as /Inbox/Projects/Acme. /Archive means the in-mailbox Archive folder, not the In-Place Archive. Matching is case-insensitive and slashes are optional. A path that does not exist logs a warning and falls back to the full mailbox. If only some rows have a scope, a warning is logged at run start.

What batch mode does for each row

  1. Creates a per-pair staging folder under the staging root, with its own manifest and state.
  2. Loads the pair's state file, or creates a fresh one.
  3. Runs the enabled phases in order: mail, archive, calendar, contacts, tasks, rules and OOF export, then mail, calendar, contacts, tasks, rules and OOF import.
  4. Records OK/failed counts and the last sync time per scope, and appends a history entry.

If a phase throws an unhandled error, that pair is marked Failed and other pairs still run.

Pre-Flight check

Click Pre-Flight... before any large run, any first run against a tenant pair, and any production cutover. It runs read-only probes; nothing is changed. For each pair it checks: source mailbox exists and is enabled, source licence (shared mailboxes under 50 GB are fine unlicensed), source Mail.Read and MailboxSettings.Read, whether the source has an In-Place Archive, top-level item counts and storage estimate, destination mailbox exists, and destination licence, scope and Mailbox-API access.

ResultMeaning
Green / ReadyAll critical checks pass with no warnings.
Yellow / AdvisorySoft warnings, for example rules and OOF will be skipped but mail works. Review the details.
Red / BlockedA critical failure such as source not found or mail read denied. This pair will fail.

Double-click a row for the full breakdown; Copy summary copies the table as text. Pre-Flight uses the loaded tenant profiles, not browser sign-in. In single mode there is a Pre-Flight button next to Stop. If you start a run without having run Pre-Flight this session, you get a reminder whose default button is No.

Provisioning destination shared mailboxes

  1. Fill the grid, click Provision Dest..., then Connect to destination Exchange Online as an admin with the Recipient Management or Mail Recipient Creation role.
  2. Each destination is shown as Exists (Type) or Will create, with editable Display Name and Alias. Rows whose source is a shared, room or equipment mailbox are ticked; rows whose source is a user mailbox are not, because receiving a user's content into a shared mailbox should be deliberate.
  3. Click Provision selected. Rows show Created or Failed: <error>, and created mailboxes get their Mbx-API ID on the grid. To retry, fix the Alias or Display Name, Rescan destinations, and provision again.
Wait before migrating

Wait 5–10 minutes after provisioning. Exchange Online needs several minutes to bring a new mailbox fully up, and migration attempts in that window can fail.

New mailboxes use the default mailbox plan and get no licence (shared mailboxes under 50 GB do not need one). Aliases must comply with any enforced naming policy.

Outcome Summary

After every Export, Import or Export + Import run, the Outcome Summary opens automatically. It shows a colour-coded status, total OK/Fail/Skip counts and duration, a hint on whether a Delta pass re-run would help, one row per phase that had activity, and (in batch mode) one row per mailbox.

  • Save report (HTML): a self-contained HTML report for a stakeholder or ticket, saved by default to the Logs folder as OutcomeReport-<timestamp>.html.
  • Open CSV log: the per-run item-level CSV.
  • OK: close.

The dialog closes itself after 60 seconds unless you move the mouse over it or press a key, so it does not sit open all night after an unattended run.

Run Failed Only

Re-runs Export + Import on only the rows whose State is Failed. Fix the underlying problem first (missing destination mailbox, permission, typo), then click Run Failed Only and confirm. The filter applies to that one run only. Tick Delta pass first if you want to avoid re-scanning everything already exported. If a mailbox fails twice this way, the problem is structural, not transient. Rows showing Skip (no src) or Skip (no dst) were never attempted; fill in the missing cell and run normally.

Throttle counter and sanity probe

The top-right of the window shows Microsoft throttling as it happens, for example Throttle: 12 total (3 last 5min). Throttled calls are retried automatically, so it is informational: a steady "last 5min" above 5 suggests lowering parallelism; zero after a few minutes means there is headroom.

Each mail export starts with a one-message sanity probe. WARN or probe failures are logged but never block the export; consistent WARNs across many mailboxes in one tenant are worth investigating.

Utilities tab

Builds ready-to-paste commands for the PowerShell utilities from the loaded profiles. Fill in the script folder (mailbox and domain refresh from the Config tab), click Copy beside a script and paste into PowerShell. The tab never runs anything itself. Test-DomainRemovalReadiness carries no credentials (it signs in interactively), and Clear-DestinationMailbox is copied with -WhatIf already applied; remove it only after reading the dry run.

Secrets on the clipboard

Include client secrets is on by default. A copied secret is readable by any process on the machine and lands in PowerShell history. Untick it for a <paste secret> placeholder, especially on a machine that is not your own.

08Workload details

Mail

With FTS on, each message is exported as its native stream (in batches of 20) and uploaded straight into the matching destination folder; missing folders are created. With FTS off, each message is exported as MIME, created as a draft on the destination, patched so it is no longer a draft, uploaded into the target folder, and the temporary draft deleted; this path loses read/unread state, categories and flags. Either way, items already present are skipped on re-runs (see Resume and dedup).

In-Place Archive

Archive items are always exported in FTS format and always imported 1:1 into the destination's archive mailbox. Archive folder paths are prefixed archive: in the manifest so they cannot collide with primary folders. Only mail items are in scope; archive calendar, contacts and tasks are not migrated.

Calendar, contacts and tasks

  • Calendar: with FTS on, events are written natively and no invitations are sent (see Calendar migration and meeting invitations). Shared and system calendars are skipped.
  • Contacts: migrated with photos.
  • Tasks: Microsoft To Do lists and tasks.

Inbox rules

Rules that move, copy or mark-read into a folder are saved with the source folder's path (for example /Inbox/Projects/Acme). On import the tool maps those paths to destination folders. If a referenced folder does not exist on the destination, that action is dropped with a warning but the rule still imports. Server-managed (read-only) rules are skipped. Re-runs skip rules whose name already exists on the destination.

Auto-reply (OOF)

The current auto-reply configuration (status, schedule, internal and external messages, external audience, including time zone) is copied to the destination. It is a single setting, so a re-run overwrites the destination's current auto-reply with the source's; if you changed it on the destination after the first run, a re-run reverts it.

OneDrive

The source drive is downloaded to a per-pair onedrive\files\ folder mirroring the source structure, then uploaded with the folder structure recreated and the original created and modified timestamps restored. Large files upload in resumable 10 MB chunks. Only the current version of each file migrates, and sharing and permissions reset to the destination user's defaults. Skipped and listed for follow-up:

SkippedListed inWhat to do
OneNote packages (.one, .onetoc2, .onepkg)skipped-onenote.csvByte copies produce orphaned notebooks. Use OneNote desktop: File → Export → .onepkg, then import on the destination.
Items shared to the user by someone elseskipped-shared.csvRe-share on the destination if needed.
Files above the size threshold (default 5 GB)skipped-toobig.csvMove manually if needed.

If the source app lacks Files.Read.All, the destination app lacks Files.ReadWrite.All, or the destination user has no OneDrive yet, the phase logs one warning and skips; other scopes continue.

Importing into a subfolder

By default OneDrive imports to the destination drive root. Import into subfolder sends it to a named folder instead, for example Migrated from contoso.com.

After cutover, always use a subfolder

OneDrive dedup compares size and modified time, not content. If a user already created a file at a path the import writes to, the migrated copy is uploaded as a new version of the user's file, and their own work drops into version history where they will not look. A subfolder makes path collisions impossible. Leave it blank only when importing before users have access to the destination.

09Performance and planning

The import ceiling

Import throughput is not limited by the tool, by concurrency or by your network. It is limited by Microsoft's per-mailbox limits, whichever is hit first, and no setting in the tool can raise them:

Limit (per mailbox)Value
Concurrent requests4
Request rate10,000 per 10 minutes (about 16.7 per second)
Upload volume150 MB per rolling 5-minute window

For any mailbox of real size the upload cap binds first. It works out to roughly 512 KB/s sustained, which sets a hard floor:

minimum hours = total mailbox bytes / 512 KB/sec / 3600

A 39 GB mailbox therefore needs about 23 hours of pure upload time no matter what. On a live tenant, mail items averaged about 284 KB in FTS form, an import ceiling of around 1.8 messages per second.

  • Estimate from gigabytes, not message count. 130,000 small messages and 130,000 attachment-heavy ones are completely different jobs. Measure-FtsItemSizes.ps1 measures real sizes.
  • The MIME path uploads each message twice and uses the quota roughly 2.7× faster than FTS.
  • Raising Items per mailbox does not help import; it only affects export.
  • If a deadline is genuinely at risk, reduce the data: a date filter or excluding large folders cuts time proportionally. This is why the bulk of old mail should move weeks ahead.

Typical throughput

  • Mail export: about 100–200 messages/minute per mailbox sequentially; about 300–500 at Items per mailbox 4 and 400–700 at 8.
  • Mail import, MIME path: about 50–100 messages/minute per mailbox. FTS import makes fewer calls, but the upload quota sets the real limit.
  • Calendar, contacts and tasks: fast.
  • Concurrent mailboxes: 2 workers ≈ 1.7–1.9× sequential; 4 workers ≈ 3–3.5×, sub-linear because the tenant-wide budget is shared.

For a 500-user migration with mixed mailbox sizes, rough estimates are 12–20 hours at Concurrency 1 / Items 1, 4–7 hours at the defaults (2 / 4), and 2–4 hours at 4 / 8 if throttling is gentle (otherwise similar to the defaults). These depend heavily on mailbox sizes and tenant load.

Dropped connections

Under sustained load Exchange Online sometimes drops the connection instead of returning 429:

Unable to read data from the transport connection:
An existing connection was forcibly closed by the remote host.

These are retried automatically with backoff, per item, per page fetch and per upload. An item that succeeded only after a retry is logged as recovered after N attempt(s), so a run that is limping is visible before it turns into failures. No action is needed unless you see gave up after 4 attempt(s) with a real HTTP status attached.

10Cutover runbook: moving the domain

This runbook covers moving a custom domain from the source tenant to the destination, including mail forwarding and the cutover window. Print it and tick as you go.

The constraint that shapes everything

A domain can be verified in exactly one tenant at a time. It must be completely removed from the source before it can be added to the destination, and removal fails while any object still references it. Microsoft's error names only a few offenders at a time, so an incomplete inventory turns a 30-minute step into a multi-hour hunt while mail is down.

Phase 1: prepare the destination (2+ weeks ahead)

  • Create all users in the destination on <tenant>.onmicrosoft.com.
  • Assign licences. An unlicensed mailbox cannot receive mail.
  • Create shared mailboxes (Provision Dest... can do this).
  • Create distribution lists, Microsoft 365 groups and Teams.
  • Configure the app registrations and RBAC for the migration.
  • Build the source object inventory. This is the step people skip.

Source object inventory

Run Test-DomainRemovalReadiness.ps1 (see PowerShell utilities), or inventory by hand:

Connect-ExchangeOnline
Connect-MgGraph -Scopes 'User.Read.All','Group.Read.All','Directory.Read.All'
$domain = 'contoso.com'

# Recipients carrying the domain in ANY proxy address
Get-Recipient -ResultSize Unlimited |
  Where-Object { $_.EmailAddresses -match $domain } |
  Select-Object Name, RecipientType, PrimarySmtpAddress,
    @{n='Addrs';e={ ($_.EmailAddresses | Where-Object { $_ -match $domain }) -join ';' }} |
  Export-Csv .\inv-recipients.csv -NoTypeInformation

# Entra users whose UPN uses the domain
Get-MgUser -All -Property UserPrincipalName,DisplayName,AccountEnabled |
  Where-Object { $_.UserPrincipalName -like "*@$domain" } |
  Select-Object DisplayName, UserPrincipalName, AccountEnabled |
  Export-Csv .\inv-users.csv -NoTypeInformation

# Guests -- invited external accounts often carry the domain and block removal
Get-MgUser -All -Filter "userType eq 'Guest'" -Property UserPrincipalName,Mail |
  Where-Object { $_.UserPrincipalName -like "*$domain*" -or $_.Mail -like "*@$domain" } |
  Export-Csv .\inv-guests.csv -NoTypeInformation

# Groups and DLs
Get-DistributionGroup  -ResultSize Unlimited | Where-Object { $_.EmailAddresses -match $domain } |
  Select-Object Name, PrimarySmtpAddress | Export-Csv .\inv-dls.csv -NoTypeInformation
Get-UnifiedGroup -ResultSize Unlimited | Where-Object { $_.EmailAddresses -match $domain } |
  Select-Object DisplayName, PrimarySmtpAddress | Export-Csv .\inv-m365groups.csv -NoTypeInformation

# SOFT-DELETED mailboxes and users: invisible in the admin centre but still hold addresses
Get-Mailbox -SoftDeletedMailbox -ResultSize Unlimited |
  Select-Object Name, PrimarySmtpAddress, WhenSoftDeleted |
  Export-Csv .\inv-softdeleted-mbx.csv -NoTypeInformation
Get-MgDirectoryDeletedItemAsUser -All |
  Select-Object DisplayName, UserPrincipalName, DeletedDateTime |
  Export-Csv .\inv-deleted-users.csv -NoTypeInformation

Soft-deleted mailboxes and users are the single most common cause of a domain removal failing in the middle of the night. Also check by hand, because none of the above finds them:

  • SharePoint site addresses and hub associations.
  • Teams with the domain in an email address.
  • App registration reply URLs / redirect URIs.
  • Exchange connectors, transport rules, accepted-domain settings.
  • Retention policies and eDiscovery holds referencing addresses.
  • Anything external pointed at the domain: SSO relying parties, SaaS logins, printers, scanners, line-of-business apps.

Phase 2: bulk migration (days ahead, source stays live)

  • Run Export + Import for every mailbox pair, with FTS enabled. Use the three-pass approach from the Quick start.
  • Migrate OneDrive / SharePoint if in scope (OneDrive is usually best done after cutover; see Phase 5).
  • Spot-check fidelity: read/unread state, categories, calendar attendees.

Users keep working in the source throughout. Anything they do afterwards is caught by the delta pass in Phase 4.

Phase 3: pre-cutover (24–48 hours before)

Drop DNS TTLs to 300 seconds, at least one old TTL period in advance (see Quick start 4.0). A 24-hour TTL lowered on the morning of cutover is still a 24-hour TTL.

RecordName / hostTypical current valueWhy it matters
MX@<tenant>.mail.protection.outlook.comWhere inbound mail is delivered. The critical one.
CNAMEautodiscoverautodiscover.outlook.comHow Outlook and phones find the mailbox.
TXT@v=spf1 include:spf.protection.outlook.com -allA stale value makes outbound mail fail SPF.
CNAMEselector1._domainkey, selector2._domainkeyselectorN-<domain>._domainkey.<tenant>.onmicrosoft.comDKIM, if enabled.
TXT_dmarcv=DMARC1; p=...Only if you publish DMARC and will change it.
CNAME / SRVlyncdiscover, sip, _sip._tls, _sipfederationtls._tcpTeams/Skype federationOnly if in use.
$domain = 'contoso.com'
Resolve-DnsName $domain                    -Type MX    -Server 8.8.8.8 | Select-Object Name,TTL,NameExchange
Resolve-DnsName "autodiscover.$domain"     -Type CNAME -Server 8.8.8.8 | Select-Object Name,TTL,NameHost
Resolve-DnsName $domain                    -Type TXT   -Server 8.8.8.8 | Select-Object Name,TTL,Strings
Resolve-DnsName "selector1._domainkey.$domain" -Type CNAME -Server 8.8.8.8 -ErrorAction SilentlyContinue | Select-Object Name,TTL,NameHost
Resolve-DnsName "_dmarc.$domain"           -Type TXT   -Server 8.8.8.8 -ErrorAction SilentlyContinue | Select-Object Name,TTL,Strings
  • Record the current values before changing anything; they are your rollback.
  • Prepare DKIM for the destination (publish the CNAMEs, do not enable yet) and draft the destination SPF record.
  • Notify users that Outlook profiles and mobile accounts will need recreating.
  • Confirm no pending Windows restart on the migration machine, and that destination licences are still assigned.

Phase 4: the cutover window

Order matters

Steps 3 and 4 must both be complete before step 5. Once the domain leaves the source, source addresses stop resolving and mail bounces, so forwarding must be in place before the domain is removed.

  1. Stop or accept inbound mail. Either pause mail flow, or accept that mail arriving now queues at senders.
  2. Final delta pass. Run with Delta pass ticked (minutes, not hours). Confirm zero failures before continuing. The Finalize... button can do this and open Forwarding in one step (see below).
  3. Set forwarding on source mailboxes to the destination .onmicrosoft.com address, with the Forwarding... dialog or manually:
    Set-Mailbox -Identity rose@contoso.com `
      -ForwardingSmtpAddress 'rose@fabrikam.onmicrosoft.com' `
      -DeliverToMailboxAndForward $false
    Forward to the .onmicrosoft.com address, not the custom domain, which does not resolve in the destination until step 7.
  4. Strip the domain from every source object. Change UPNs to .onmicrosoft.com; remove domain proxy addresses from mailboxes, groups and DLs; rename Teams and SharePoint addresses; purge the soft-deleted mailboxes and users found in Phase 1. Budget hours for this step, not minutes. It is the long pole.
    # Purge soft-deleted objects that still hold the domain
    Get-Mailbox -SoftDeletedMailbox -ResultSize Unlimited |
      Where-Object { $_.EmailAddresses -match $domain } |
      ForEach-Object { Remove-Mailbox -Identity $_.DistinguishedName -PermanentlyDelete -Confirm:$false }
    
    Get-MgDirectoryDeletedItemAsUser -All |
      Where-Object { $_.UserPrincipalName -like "*@$domain" } |
      ForEach-Object { Remove-MgDirectoryDeletedItem -DirectoryObjectId $_.Id }
  5. Remove the domain from the source tenant: Microsoft 365 admin center → Settings → Domains → Remove. It refuses and names blocking objects if step 4 was incomplete; fix and retry. Removal can take 15–60 minutes to fully release.
  6. Add and verify the domain in the destination. Publish the TXT record it gives you and verify.
  7. Set primary addresses in the destination on every object:
    Set-Mailbox -Identity rose@fabrikam.onmicrosoft.com `
      -EmailAddresses @{ add = 'SMTP:rose@contoso.com' }
  8. Update public DNS: MX → destination; autodiscover CNAME → autodiscover.outlook.com; SPF → the destination's include:; enable DKIM in the destination; DMARC if used.
Purging is permanent

The purge commands in step 4 permanently delete soft-deleted mailboxes and users. Review the Phase 1 inventory before running them.

Phase 5: verification and follow-up

  • Send inbound mail from an external address and confirm delivery.
  • Send outbound and confirm SPF/DKIM pass at the recipient.
  • Confirm internal mail between destination mailboxes.
  • Recreate Outlook profiles; re-add mobile accounts.
  • Run one final delta pass for anything that landed in the source during the window.
  • Migrate OneDrive into a subfolder (see OneDrive).
  • Remove source forwarding once mail is confirmed flowing.
  • Restore DNS TTLs to normal values.
  • Keep the source tenant for 30–60 days for rollback and archive.

The Finalize... button

A batch-mode cutover helper: it ticks Delta pass, runs Export + Import on the whole grid, and, only if the run succeeds, opens Forwarding.... It refuses to start if any row lacks a source or destination UPN, and its confirmation defaults to Cancel. Use it only after a verified full migration; not for a first-time migration, when you suspect destination problems, or when you do not intend to set up forwarding.

The Forwarding... dialog

  1. Click Forwarding..., then Connect to source Exchange Online as a source admin with the Recipient Management or Mail Recipients role.
  2. Mailboxes with no forwarding are ticked. Mailboxes already forwarding elsewhere are highlighted and unticked so you do not overwrite them silently.
  3. Check Forward To (defaults to the destination UPN; use the .onmicrosoft.com address if the domain has not moved yet) and Keep Copy (default on, so users can still use the old mailbox if something goes wrong).
  4. Click Apply forwarding. After the transition period (about 30 days), reopen the dialog and click Remove forwarding.
External forwarding may be quarantined

If the source tenant's outbound spam policy has automatic forwarding Off (the default for newer tenants), a warning appears: external forwards will be quarantined. Set it to On or Automatic, or use a tenant-to-tenant mail flow connector. The destination's anti-spam may also distrust forwarded mail because the sender's SPF/DKIM/DMARC will not authenticate; connectors are the production fix.

What actually goes wrong

  • Domain removal blocked by a forgotten object. By far the most common failure. Soft-deleted mailboxes, guest accounts and app registration reply URIs are the usual culprits, and none appear in the admin centre's normal views.
  • Forwarding set too late. Once the domain leaves the source, mail to source addresses bounces.
  • Autodiscover caching. Outlook caches aggressively and often needs a profile rebuild regardless of DNS. Plan for it.
  • Licences. An unlicensed destination mailbox silently rejects mail. Verify before the window.
  • TTL not lowered in advance. If the MX TTL is 86400 and you lower it the same morning, mail can keep routing to the old tenant for up to 24 hours. Lower it at least one full old-TTL period ahead, and confirm with Resolve-DnsName ... -Server 8.8.8.8 that the new value is being served before you commit to a window.

11Verification

Zero failures means nothing that was attempted failed; it says nothing about items never enumerated. During export, watch for NOT SEEN lines and check each folder summary's "saw X of Y". After the import finishes, run Compare-FolderCounts.ps1 (a handful short is normal; hundreds means re-run) and Compare-MailFidelity.ps1 on a sample of folders. Do not verify by opening folders in Outlook: that marks messages read and destroys the evidence. Finish with a spot check of one user's folders, old mail and calendar, and save the Outcome Summary report as a record.

12Resume, files and logs

Resume and dedup

The tool is designed to be re-runnable: you can stop and restart at any time without creating duplicates on the destination.

WorkloadHow re-runs avoid duplicates
MailThree layers: the export manifest (skips messages already exported, by Internet Message-ID); the import sidecar imported-files.txt (skips files already imported); and, with Server-side dedup check on, a check of Message-IDs already in each destination folder.
CalendarA hash of start, end, subject and body preview.
ContactsA hash of email, display name, given name and surname.
TasksTitle and due date, per task list.
Inbox rulesRule name. Existing names are skipped.
Auto-replyNone needed: a re-run overwrites the destination setting.
OneDriveExport manifest, import sidecar, and a destination check of path, size and modified time (2-second tolerance).

Interrupted large OneDrive uploads resume from the last acknowledged byte, provided the same staging folder is reused, the upload session has not expired (about an hour after last activity), and the local file has not changed size. Otherwise a fresh upload starts from the beginning and the log says so.

File layout

%APPDATA%\WT-O365xO365\
├── Profiles\
│   ├── old.profile.xml             # source profile, encrypted
│   └── new.profile.xml             # destination profile, encrypted
├── Logs\
│   ├── Export-20260510-103015.csv
│   ├── Import-20260510-103216.csv
│   └── Both-20260510-135215.csv    # concurrent mode writes one combined CSV
└── Staging\                        # default base; configurable via Browse
    ├── alice@contoso.com\          # per-pair subfolder in batch mode
    │   ├── .batchstate.json        # per-pair state + history
    │   ├── manifest.csv            # mail export manifest
    │   ├── imported-files.txt      # mail import sidecar
    │   ├── *.eml / *.fts           # exported mail
    │   ├── archive\    (manifest.csv, *.fts)
    │   ├── calendar\   (manifest.csv, attachments\, *.json)
    │   ├── contacts\   (manifest.csv, photos\, *.json)
    │   ├── todo\       (manifest.csv, imported-files.txt, *.json)
    │   ├── rules\      (manifest.csv, one *.json per rule)
    │   ├── oof\        (automaticReplies.json)
    │   └── onedrive\
    │       ├── manifest.csv
    │       ├── imported-files.txt
    │       ├── skipped-onenote.csv
    │       ├── skipped-shared.csv
    │       ├── skipped-toobig.csv
    │       ├── sessions\           # in-progress upload sessions; deleted on success
    │       └── files\              # mirrors the source folder structure 1:1
    └── bob@contoso.com\
        └── ...

In single-mailbox mode (empty grid), staging files go directly into the staging root rather than a per-pair folder.

File formats

Mail manifest (manifest.csv)

InternetMessageId,Subject,SourceFolderId,SourceFolderPath,FileName,SourceMailboxKind,FileType

SourceMailboxKind is primary or archive. FileType is eml (MIME) or fts, and import routes on it. Archive rows are always fts.

Do not hand-edit FileType

A mismatch between the column and the file on disk fails the import for that row.

Other manifests

calendar\manifest.csv  EventId,DedupHash,Subject,Start,End,IsAllDay,IsRecurring,SourceCalendarId,SourceCalendarName,SourceCalendarIsDefault,FileName,SourceMailbox
contacts\manifest.csv  ContactId,DedupHash,DisplayName,GivenName,Surname,EmailAddress,SourceFolderId,SourceFolderName,SourceFolderIsDefault,FileName,PhotoFile,SourceMailbox
todo\manifest.csv      TaskId,Title,Status,DueDateTime,SourceListId,SourceListName,SourceListWk,FileName,SourceMailbox
rules\manifest.csv     RuleId,DisplayName,IsEnabled,Sequence,FileName,SourceMailbox
onedrive\manifest.csv  ItemId,Path,Size,Created,LastModified,Status,SkipReason

Each rule is saved in full as its own JSON file. In the OneDrive manifest, Status is OK, Error or Skipped, and SkipReason is OneNote, SharedItem or TooBig. oof\automaticReplies.json holds the full auto-reply setting.

Per-pair state (.batchstate.json)

{
  "schemaVersion": 1,
  "source": "alice@contoso.com",
  "destination": "alice@fabrikam.com",
  "mailboxApiId": "MBX:...@...",
  "scopes": {
    "mail":     { "lastSyncUtc": "2026-05-10T20:30:15.123Z", "ok": 228, "failed": 0, "lastError": null },
    "archive":  { ... },
    "calendar": { ... },
    "contacts": { ... },
    "tasks":    { ... },
    "rules":    { ... },
    "oof":      { ... },
    "onedrive": { ... }
  },
  "history": [
    { "startUtc": "...", "endUtc": "...", "phase": "Both", "result": "OK", "note": "" }
  ]
}

History keeps the last 20 runs. The file is saved atomically, so a crash mid-write does not leave a half-written file.

Per-run logs (Logs\Export-*.csv, Import-*.csv, Both-*.csv)

TimestampUtc,Phase,Mailbox,FolderPath,FolderId,MessageId,Subject,ReceivedDateTime,SentDateTime,Status,Detail

One row per item-level event; Status is OK, Error or Skipped. In concurrent mode, workers append to one file under a lock, so rows from different mailboxes interleave but never corrupt each other. Remember that the on-screen activity log is separate and is not written to disk unless you click Save log.

13Troubleshooting

Connection and permission errors

MessageCause and fix
AADSTS7000215Invalid client secret. Re-copy it from the portal and re-save the profile.
AADSTS50173Wrong tenant ID. Check it against the directory's Properties page.
HTTP 401 invalid_tokenUsually clock skew. Run w32tm /resync and retry.
404 with ErrorInvalidUserThe mailbox UPN does not exist in that tenant. Check spelling; it must match an actual user.
"Could not resolve Mailbox-API IDs ... HTTP 403"RBAC for Applications is not configured on the destination. Set it up (preferred), or supply the Mbx-API ID per mailbox.
HTTP 429 Too Many RequestsNormal; retried automatically. If constant in concurrent mode, lower the concurrency.

"Manual Mailbox-API ID override must start with 'MBX:'"

The Mbx-API ID field contains something that is not an MBX: token, most often the mailbox UPN typed into the wrong box. Mail import fails immediately while calendar, contacts, rules and OOF succeed; that asymmetry is the giveaway. Either clear the field (it is optional) or click Lookup... to fill it correctly, then re-save the profile, or the bad value reloads next launch. Despite an accompanying log line about mailbox permissions, this is a configuration error, not a permissions one.

Missing items

"!! /Folder: enumerated N of M items -- X NOT SEEN"

The export could not enumerate the whole folder. Re-run Export: staged items skip and the missed ones are picked up. Repeat until the line stops appearing. If it persists, the log also says whether enumeration fell back to ordinary paging for that folder.

Items missing on the destination, but zero failures reported

Run Compare-FolderCounts.ps1. Mailboxes migrated with builds that predate per-folder reconciliation should be checked this way.

Mail items arrive as drafts

The MIME import round trip did not complete. Check the destination app has all four mailbox-class permissions plus the matching RBAC roles. Re-running import fixes it in place.

Staging file errors

".eml missing" or "staged file is 0 bytes" on import

The manifest has a row but the file is not on disk or is empty, usually left by an interrupted export. These rows fail on every re-run. With the import stopped, drop the orphaned rows (this keeps a backup copy of the manifest):

$s = "$env:APPDATA\WT-O365xO365\Staging"
Copy-Item "$s\manifest.csv" "$s\manifest.csv.bak"
Import-Csv "$s\manifest.csv" |
  Where-Object { Test-Path (Join-Path $s $_.FileName) } |
  Export-Csv "$s\manifest.csv" -NoTypeInformation -Encoding UTF8

Adjust $s to the staging folder (or per-pair subfolder) you are using.

"Failed to save batch state ... file in use by another process"

Two batch rows have the same source UPN and wrote the same state file at once in concurrent mode. Use distinct source UPNs per row.

"Unable to read data from the transport connection"

Exchange Online closed the connection under load. Retried automatically; no action unless you see gave up after 4 attempt(s) with an HTTP status.

Calendar, rules and auto-reply

  • People received meeting invitations or cancellations: FTS was off for the calendar. Nothing can recall them; tick FTS before the next mailbox.
  • Calendar attachments missing: the attachment was over 3 MB (logged warning) or calendar import was interrupted. Re-running calendar import is safe.
  • Rules missing: check the destination app has MailboxSettings.ReadWrite. "Skipped existing rule" means a rule with that name already exists.
  • Auto-reply did not transfer: check MailboxSettings.ReadWrite. Some tenants block external-audience auto-replies, which fail with a specific Graph error.

OneDrive

  • Skipped with "User's mysite not found": the destination user has no OneDrive yet (never signed in, no OneDrive licence, or a shared mailbox). Have the user sign in to OneDrive, or have an admin trigger creation from SharePoint admin, wait 5–10 minutes and re-run.
  • Skipped with HTTP 403: the source app lacks Files.Read.All or the destination app lacks Files.ReadWrite.All. Add it and grant admin consent.
  • Large upload failed partway: re-run import with the same staging folder; it resumes from the last good byte if the session is still valid.
  • Destination shows today's date: the timestamp update after upload failed (rare). The file is uploaded; a warning is logged per file.

Run stopped or slow

  • Stopped overnight: the PC slept or restarted. Run it again; it resumes. Tick Keep this computer awake and clear any pending restart.
  • Much slower than expected: estimate from gigabytes, not message count (see The import ceiling).
  • A mailbox produces the same error on every item: set Stop mailbox after N fails, fix the cause, then use Run Failed Only.

14PowerShell utilities

WT-O365xO365 ships with seven standalone PowerShell scripts. They answer questions a migration tool cannot sensibly answer about itself: whether the tenant can do the migration you are about to attempt, whether what arrived matches what left, and whether the domain can be released. Each runs from an ordinary PowerShell prompt; the application does not need to be running. The Utilities tab builds each command for you.

ScriptPurposeWrites?
Test-FtsExportReadinessConfirm the source tenant can perform FTS exportNo
Test-IdNamespaceConfirm Graph and Mailbox-API IDs share a namespaceNo
Measure-FtsItemSizesMeasure real item sizes to project migration timeNo
Compare-MailFidelityVerify read state, categories, flags and timestamps survivedNo
Compare-FolderCountsFind folders where items did not all arriveNo
Clear-DestinationMailboxReset a destination between test runsYes
Test-DomainRemovalReadinessFind objects blocking domain removalNo
Only one script deletes

Six of the seven are strictly read-only. Only Clear-DestinationMailbox.ps1 modifies anything. Always run it with -WhatIf first.

Authentication and secrets

All except Test-DomainRemovalReadiness.ps1 authenticate like the application: tenant ID, client ID and client secret from the same app registrations, using the client-credentials flow. Tokens last one hour, so a script left open longer must be re-run rather than resumed. Test-DomainRemovalReadiness.ps1 signs in interactively, because it reads directory objects and Exchange configuration that application permissions do not cover.

Secrets in history

Secrets are passed as plain strings on the command line and will appear in your PowerShell history. For a one-off migration that is usually acceptable; rotate the secret afterwards if it is not disposable.

When to run each

  1. Test-FtsExportReadiness.ps1: confirm the source supports FTS and that batching helps.
  2. Test-IdNamespace.ps1: confirm the ID namespace assumption holds.
  3. Measure-FtsItemSizes.ps1: project wall-clock time from real data.
  4. Migrate one mailbox, one small folder.
  5. Compare-MailFidelity.ps1: confirm read state and categories survived.
  6. Clear-DestinationMailbox.ps1 -WhatIf: reset between test iterations.
  7. Full migration.
  8. Compare-FolderCounts.ps1: find folders that came up short.
  9. Compare-MailFidelity.ps1 on a sample of real folders, as acceptance evidence.
  10. Test-DomainRemovalReadiness.ps1: weeks ahead, and again immediately before cutover.

Test-FtsExportReadiness.ps1

Confirms a tenant can perform native FTS export, and measures the batching gain. FTS export depends on two separate permission systems: Graph application permissions (MailboxItem.Read.All, MailboxFolder.Read.All) and a separate Exchange RBAC assignment (Application MailboxItem.Read). The portal shows green checkmarks for the Graph half while the export still returns 403; only calling the API settles it.

.\Test-FtsExportReadiness.ps1 `
    -TenantId <tenant-id> -ClientId <client-id> -ClientSecret '<secret>' `
    -Mailbox user@contoso.com

It decodes the token to list exactly which Graph permissions were consented (catching "added but never admin-consented"), resolves the Mailbox-API ID, walks the folder tree, exports one item, then exports twenty and compares per-item timing:

--- exportItems -- batch of 20 (the key throughput question)
    returned 20/20 items, 3,793,554 total FTS bytes in 473 ms
    per-item: 24 ms batched vs 89 ms single  =>  3.8x

A ratio near 4× means the export side of an FTS migration is nearly free. If fewer than 20 items return, batching is partial. On failure the summary prints the remediation, including the New-ManagementRoleAssignment commands and Get-ManagementRoleAssignment -App <ClientId>. If you have just granted RBAC roles, wait ten minutes and re-run.

Test-IdNamespace.ps1

Determines whether Graph message IDs are accepted by the Mailbox-API export. If they are, the tool can enumerate through Graph (with full metadata: subject, Message-ID, dates) and export the same IDs as FTS, keeping meaningful run logs and server-side dedup. If not, only sparse Mailbox-API metadata is available.

.\Test-IdNamespace.ps1 `
    -TenantId <tenant-id> -ClientId <client-id> -ClientSecret '<secret>' `
    -Mailbox user@contoso.com -FolderName Inbox
--- DECIDING TEST -- exportItems with a Graph message ID
    ACCEPTED -- 186,432 FTS bytes returned
    Graph IDs and Mailbox-API IDs ARE the same namespace.

Design A (accepted) means full metadata is preserved; Design B means Mailbox-API metadata is all you get. This has been confirmed against live tenants and the FTS path is built on it; the script remains useful for an unfamiliar tenant or if Microsoft changes the API.

Measure-FtsItemSizes.ps1

Measures real per-folder item sizes to project wall-clock time. Import time is set by the 150 MB per 5 minutes per-mailbox upload cap (see The import ceiling), so it depends on gigabytes, not message count.

.\Measure-FtsItemSizes.ps1 `
    -TenantId <tenant-id> -ClientId <client-id> -ClientSecret '<secret>' `
    -Mailbox user@contoso.com -SampleSize 20
  Inbox                     114,042 items   avg  298.0 KB   36.4 ms/item
  Deleted Items              17,584 items   avg  264.3 KB   84.5 ms/item
  Sent Items                  1,585 items   avg   72.2 KB   54.3 ms/item

  Average FTS size   : 237.2 KB
  Import ceiling     : 2.16 messages/sec
  130,000 messages   : ~16.7 hours at the cap
Weight the average yourself

The script samples each folder equally. If the Inbox is most of the mailbox, its average dominates, and the true weighted average is much closer to the Inbox figure than to the printed one.

Compare-MailFidelity.ps1

Verifies a migrated folder kept read/unread state, categories, flags and timestamps. Counts prove nothing about fidelity, and opening the folder in Outlook marks messages read and destroys the evidence. This script reads both mailboxes through Graph and changes nothing.

.\Compare-MailFidelity.ps1 `
  -SrcTenantId <tenant-id> -SrcClientId <client-id> -SrcSecret '<secret>' `
  -DstTenantId <tenant-id> -DstClientId <client-id> -DstSecret '<secret>' `
  -SrcMailbox rose@contoso.com -DstMailbox rose@fabrikam.com `
  -Folder '/Inbox/Important'

-Folder takes a well-known name (archive, inbox) or a path; -Sample defaults to 50. Items are matched on Internet Message-ID and reported as MATCH, DIFFER (naming the changed properties) or MISSING.

A zero-unread source proves nothing

If the source folder has nothing unread, a match proves nothing, and the script warns you. Mark two or three source messages unread and categorise one, re-run the migration, then compare. Shared mailboxes are usually all-read.

Use it after the first migration of any mailbox (on one small folder, before a long run), whenever you change export format, and as acceptance evidence.

Compare-FolderCounts.ps1

Finds folders where the migration did not land everything. A run reporting 115,000 successes can still hide a folder that silently lost 40 messages; a total-versus-total comparison cannot tell you where to look.

.\Compare-FolderCounts.ps1 `
  -SrcTenantId <tenant-id> -SrcClientId <client-id> -SrcSecret '<secret>' `
  -DstTenantId <tenant-id> -DstClientId <client-id> -DstSecret '<secret>' `
  -SrcMailbox rose@contoso.com -DstMailbox rose@fabrikam.com `
  -CsvPath .\folder-compare.csv
  • -IgnoreFolders: paths to exclude. Defaults to /Conversation History and /Sync Issues, matching the application's default skip list. If you used a different skip list, pass it here, or deliberately skipped folders show as failures and bury the real ones.
  • -MinDelta: only report differences of at least this size.
Folder                          Source   Dest  Delta  Status
/Inbox/Projects                    412    398    -14  SHORT
/Inbox/Receipts                      1      0     -1  SHORT
/Archive                          7959   8102   +143  OVER

Folders are matched by path, not ID, because destination folders are recreated with new IDs. This also catches folders present on only one side.

StatusMeaning
MATCHCounts identical.
SHORTFewer on destination: items did not arrive.
MISSING-DESTFolder absent on destination entirely.
OVERMore on destination.
EXTRA-DESTFolder exists only on destination.
Reading the results

OVER is not automatically a fault: earlier test runs, items users created directly, or a destination that was not empty all produce positive deltas. Run this only after the import finishes; mid-run every folder is short and the output tells you nothing.

Clear-DestinationMailbox.ps1

Resets a destination mailbox between test runs without emailing anyone. After several test runs a destination holds items from different attempts, and no comparison means anything.

This script deletes data

It is the only utility that deletes anything. Always run it with -WhatIf first, read the dry run, and only then run it for real.

Why it will not delete meetings with attendees

Deleting a meeting you organise sends a cancellation to every attendee, and removing an attendee also sends that attendee a cancellation, so "strip the attendees, then delete" guarantees the mail goes out. An earlier version of this script did exactly that and sent several hundred spurious cancellations to real recipients. That version, Clear-DestinationCalendars.ps1, is superseded; delete it if you have a copy.

CategoryActionNotifies?
Plain appointments (no attendees)Deleted via GraphNo
Contacts, default and custom foldersDeleted via GraphNo
To Do tasks and migration-created listsDeleted via GraphNo
Non-default calendarsFolder deleted wholesaleNo
Meetings with attendees, default calendarSkipped—

Graph cannot delete an organised meeting silently, so for those the script prints compliance-search commands, which work at the store level and notify nobody. The purge is a hard delete, runs in batches and may need repeating; allow 10–30 minutes.

Connect-IPPSSession
New-ComplianceSearch -Name 'CalPurge' -ExchangeLocation 'rose@fabrikam.com' `
  -ContentMatchQuery 'kind:meetings'
Start-ComplianceSearch -Identity 'CalPurge'
# wait for Status = Completed, then:
New-ComplianceSearchAction -SearchName 'CalPurge' -Purge -PurgeType HardDelete
# ALWAYS dry-run first
.\Clear-DestinationMailbox.ps1 -TenantId <tenant-id> -ClientId <client-id> `
  -ClientSecret '<secret>' -Mailbox rose@fabrikam.com -WhatIf

Then drop -WhatIf. Options: -Scope Calendar,Contacts,Tasks (default All), -CalendarName 'Name' (one calendar), -DeleteNonDefaultCalendars (also remove emptied secondary calendars). Calendars owned by others and read-only system calendars are skipped. Deleted items go to Deleted Items, so there is a recovery window.

Test-DomainRemovalReadiness.ps1

Finds every object blocking removal of a custom domain from the source tenant. Microsoft's error names only a few offenders at a time; finding the rest by trial and error during the window turns a thirty-minute step into hours. The script is read-only.

.\Test-DomainRemovalReadiness.ps1 -Domain contoso.com

Sign in as a Global Administrator of the source tenant (it prompts for Graph and Exchange Online). Requires the Microsoft.Graph and ExchangeOnlineManagement modules. It checks user UPNs, guests, soft-deleted users, recipients carrying the domain in any proxy address, soft-deleted mailboxes, groups and DLs, transport rules and accepted domains, and app registration reply URLs and identifier URIs. The soft-deleted categories (30-day retention, invisible in normal admin views) cause most real failures. Secondary proxy addresses on the domain block removal just as much as primary ones.

Blockers print by category with the object, matching address and fix, and a CSV with the same detail is written alongside:

  [Group]
    - Sales Team  <sales@contoso.com>
        smtp:sales@contoso.com
        fix: Update the group address, or delete if obsolete.

  [User UPN]
    - Rose Example  <rose@contoso.com>
        UserType=Member, Enabled=True
        fix: Set-MgUser / Set-Mailbox to change the UPN
Scan gaps

If the summary reports scan gaps, do not treat a low blocker count as ready: whole categories went unchecked. Run Import-Module ExchangeOnlineManagement and Connect-ExchangeOnline, then re-run.

Not checked, verify by hand: SharePoint site addresses, Teams email addresses, retention policies and eDiscovery holds, and external systems. Run it weeks before cutover, again after remediation, and immediately before the window.

Common pitfalls

  • Treating a clean result from a partial scan as success. Test-DomainRemovalReadiness and Compare-MailFidelity can both produce zero-problem output for the wrong reason (an unscanned category, or a source folder with nothing unread). Both warn; read the warnings.
  • Running comparisons mid-migration. Every folder is short until the import finishes.
  • Forgetting that OVER is usually benign. It is normally prior test runs.
  • Losing an hour to an expired token. Tokens last sixty minutes; re-run a script that has been open longer.
  • Leaving secrets in shell history. Rotate afterwards if the secret is not disposable.

15Getting help

Questions, bug reports or feature requests: email info@taucher.llc. You'll reach the people who wrote the software.

When reporting a problem, use Save log on the Run tab and attach the saved .txt: its header carries the build version, machine and PowerShell version. The Outcome Summary's HTML report and the per-run CSV from the Logs folder are also useful. Never send client secrets.