Skip to content

Migrating from Microsoft 365

The generic engine (Migration) copies mail from any IMAP server. Microsoft 365 is the same bargain as Google Workspace: one app-only credential, no passwords collected, for any number of mailboxes.

One difference runs through everything. Microsoft’s app-only grant is tenant-wide rather than per-user, so a single token serves every mailbox the service principal was given, instead of one being minted per person. That makes discovery cheaper, and it means shared mailboxes come across without anything extra.

This is the detail that catches people, and it is worth reading before you set anything up.

Resource Token scope What it does
Microsoft Graph https://graph.microsoft.com/.default lists users and groups, reads mailbox settings
Exchange Online https://outlook.office365.com/.default opens mailboxes over IMAP

They are different audiences. The token that lists your users cannot open a mailbox, and vice versa. An app registration that discovers perfectly and then fails on every single copy is almost always this — usually IMAP.AccessAsApp was never granted, was granted but never admin-consented, or the app’s service principal was never registered in Exchange Online and given the mailboxes (step 8). Discovery mints only the Graph token (M365Client.get); the Exchange Online one is first minted by the connection test — provided you give it a mailbox address — or else by the first copy. Without an address the test lists the directory, which proves only the Graph half.

What the app registration can and cannot do

Section titled “What the app registration can and cannot do”
Can List mailboxes (shared ones included) and their proxy addresses; list mail-enabled groups and members; read each mailbox’s automatic replies and inbox rules; open over IMAP each mailbox its service principal was given.
Cannot Report mailbox sizes (a separate reporting permission, and a day behind). See mailbox-level forwarding set by an administrator — that is not in Graph at all; see below.

Nothing here writes to the source. Every call is a GET and a test asserts it.

In the customer’s Entra admin centre (entra.microsoft.com), as a Global Administrator:

  1. App registrations → New registration. Single tenant. No redirect URI — this is app-only, which is exactly why there is nothing to register on our side.

  2. Note the Application (client) ID and the Directory (tenant) ID.

  3. Certificates & secrets → New client secret. Copy the value immediately; Entra never shows it again.

  4. API permissions → Add a permission → Microsoft Graph → Application permissions, then add:

    User.Read.All
    Group.Read.All
    MailboxSettings.Read (only for "Read each mailbox's settings")
  5. Add a permission → APIs my organization uses → Office 365 Exchange Online → Application permissions, then add:

    IMAP.AccessAsApp
  6. Grant admin consent for the whole list. Nothing works until this is done, and the failure it produces looks like a bad password rather than a missing consent.

  7. Make sure IMAP is enabled for the mailboxes being migrated. It is on by default; an organisation with an authentication policy that disables it will fail every mailbox at login.

  8. Register the app’s service principal in Exchange Online and give it the mailboxes. Consent alone opens nothing: Microsoft requires this step for app-only IMAP, and without it every mailbox fails at login exactly as an unconsented permission does. In Exchange Online PowerShell, as an Exchange administrator:

    Terminal window
    Install-Module -Name ExchangeOnlineManagement
    Connect-ExchangeOnline -Organization <tenant ID>
    New-ServicePrincipal -AppId <application (client) ID> -ObjectId <object ID>
    Add-MailboxPermission -Identity "someone@example.com" -User <service principal ID> -AccessRights FullAccess

    The -ObjectId is the one on the Enterprise applications page for the app, not the one on its App registrations page; the wrong one fails authentication. Get-ServicePrincipal | fl shows the identifier to pass as -User. Add-MailboxPermission is per mailbox, so run it for every mailbox being migrated, shared mailboxes included. Microsoft’s page is Authenticate an IMAP, POP or SMTP connection using OAuth → Register service principals in Exchange.

The app can therefore open only the mailboxes it was given. The connector never assumes it can open more, and a mailbox that was left out fails as that mailbox’s own failure, not the job’s.

Then, in the wizard: the tenant, the client ID, the client secret, and optionally the one mail domain to read.

  1. New migration → Microsoft 365, then Test with a real mailbox address. This mints both tokens and opens the mailbox — a genuine end-to-end check of the consent.
  2. Add the domains on the Domains page for any that do not exist here yet.
  3. Read the source on the job’s Discover step.
  4. Create and map the missing mailboxes. Each gets a new password, shown once. This source reports no quotas, so each mailbox takes the domain default, applied when the migration ends: until then it has none, so the copy cannot fail on a quota (migration.md → Quotas). A source account found suspended is information only: it is created as an ordinary active mailbox, so switch on Suspended afterwards if needed.
  5. Import the aliases, automatic replies and inbox rules.
  6. From here it is an ordinary migration: Migrating a domain and cutting over.

Every account with a mail address — which is Graph’s own signal that Exchange has actually given it a mailbox. Accounts without one (unlicensed users, guests, service principals) are skipped and counted in a warning, because listing them would produce a run that fails on each.

Shared mailboxes come across. Exchange models one as a user object with sign-in blocked, so “cannot sign in” cannot be read as “suspended person” — that would flag every shared mailbox in the tenant. The connector separates them by licence: a shared mailbox is blocked and unlicensed, while a genuinely suspended employee usually still holds their licence.

Discovery has ceilings. Every Graph listing — users, groups, and each group’s members — stops at 5,000 entries (M365Client.collection) and says nothing when it does, so a larger tenant is silently truncated. Automatic replies and inbox rules are read for the first 200 mailboxes only; past that the rest are skipped with a warning (maxSettingsUsers, m365.ts).

Sizes are not shown; Microsoft reports mailbox usage only through the reporting API, on a separate permission and a day behind.

proxyAddresses holds every address a mailbox answers to, and the case is the whole distinction: SMTP: is the primary, smtp: an alias. Aliases become aliases here.

Mail-enabled groups and distribution lists become aliases to their members. The wizard’s Import distribution lists as aliases switches it off.

A Microsoft 365 Group additionally has a shared mailbox of its own, which an alias is not, and which is not in /users — so it is not migrated by this job at all. The discovery warning counts them so nobody finds the group’s shared history missing after cutover. Migrate each group mailbox as its own job if it matters.

The window comes across, and a reply whose window has already closed is imported switched off.

Three honest differences. The first two are reported as one warning each, with a count of the mailboxes affected, on the discovery screen:

  • Replies are HTML; a Sieve vacation body is plain text. It is flattened, and you are told how many were, so you can reword anything that mattered.
  • Outlook has separate internal and external replies; this server sends one. Where the mailbox replied to outsiders at all (externalAudience other than none, and an external message set), the external wording is kept, because the internal one routinely names people and extension numbers that an outsider should not read. A mailbox that replied only internally keeps its internal wording, which outsiders will now receive too, and discovery says how many mailboxes that applies to.
  • Exchange replies once per sender for the whole absence and has no interval setting. Thirty days is the closest our range gets to “once”; one day would be far chattier than the source.

Two Outlook words mean something different from what they mean in Sieve, and both are handled rather than approximated:

  • “Delete” does not delete. Outlook’s delete moves a message to Deleted Items and is recoverable; permanentDelete is the one that destroys it. So delete becomes fileinto "Trash" and only permanentDelete becomes discard. Mapping the first onto discard would quietly turn a rule the user can undo into one nobody can.
  • Exceptions have no counterpart. Outlook joins conditions with “and” and then exceptions with “or” over the top. Folding those into negated conditions changes what the rule matches, so any rule with an exception is refused with a note.

Translated:

Outlook Here
From / sender includes an address test on From
Sent to / recipient includes an address test on To
Subject includes a Subject header test
Body includes a body test
Sent to me / copied to me an address test against this mailbox’s own address
Within a size range size tests, converted out of kilobytes
Move to folder fileinto, with the folder’s full path rebuilt
Copy to folder copy
Delete fileinto "Trash" — recoverable, as Outlook’s is
Permanently delete discard
Forward to / redirect to send on and keep a copy
Mark as read flag \Seen
Stop processing more rules the rule’s stop flag

A rule that moves and deletes files into Trash only. Filing the message into both the rule’s folder and Trash would store two copies, one of them in a visible folder the user asked to have the mail deleted from, so the move is left out and the rule’s note names the folder. This follows how Outlook applies the two actions — the message ends in Deleted Items — though no such rule has yet been captured from a live tenant. A rule that copies and deletes keeps both, because a copy is a second message in Outlook too. Rules imported by 0.3.0-rc.6 and earlier filed a move-and-delete into both folders and keep both copies until the move is removed in the filter editor, or the rule is imported again.

A rule that filed into Clients/Acme files into Clients/Acme: the connector walks parentFolderId up to the mailbox root rather than using the folder’s own name, which would have put it at the top level.

Forward and redirect differ in whether the copy stays. Both are mapped to “send on and keep”, because the two failure modes are not symmetrical — an extra copy in the mailbox is visible and harmless, while dropping a copy the source kept loses mail with nothing to recover it from. The note says when that happened.

Dropped, and named in the note: setting importance, and assigning Outlook categories.

Where the admin reads these notes: the result under Import on the migration job’s discovery panel. A refused filter is under n not done, as filter on <address> with its reason. A filter imported with a loss is under its own list, n filters imported with something left out, as filter "<name>" on <address> with the note. It is kept apart because that rule is running. The API returns these as skipped and imperfect in MigrationImportResult. The discovery list itself only counts filters.

Refused, with the reason reported: exceptions; “body or subject includes” and “sent to or copied to me” (an or inside an and); “message header includes”; “has an attachment”; “sent only to me” and “not sent to me”; rules on importance, sensitivity, automatic replies, categories, meeting requests, receipts, voicemail and the rest; any condition key the importer does not recognise, named in the note, since ignoring it would make the rule match more mail than it did in Outlook; “forward as attachment”; a move or a copy into a folder that no longer exists; several values in one condition alongside another condition; and more than 20 conditions or 10 actions — the limits of one rule here — such as a forward to 11 recipients, which cut to fit would silently stop reaching some of them. The Gmail and cPanel translators refuse it the same way.

A disabled rule is imported still disabled, so an admin can see what was there without it suddenly starting to fire.

Forwarding — the gap worth knowing about

Section titled “Forwarding — the gap worth knowing about”

Mailbox-level forwarding, the kind an administrator sets in the Exchange admin centre (ForwardingSmtpAddress), is not exposed by Microsoft Graph. Only forwarding configured as an inbox rule is imported, and discovery says so every time rather than letting it be discovered by mail silently not being forwarded after cutover. Check the Exchange admin centre for the rest.

Microsoft’s access tokens live about an hour, and a large mailbox does not copy in an hour. The token is written to a file and imapsync is given the path: it re-reads that file on every reconnect, and the worker mints a fresh token every twenty minutes underneath the running copy.

The grant is tenant-wide, so one token would serve every mailbox — but each mailbox’s run builds its own connector, so each writes, refreshes and removes its own token file: random name, mode 0600, in the worker’s own volume, unlinked when that mailbox’s run ends.

Code Means
source_auth (400) The app registration, the secret, or the consent. AADSTS7000215 is a wrong secret; Authorization_RequestDenied is a missing Graph permission.
source_tls (400) A certificate on the way to Microsoft did not verify — usually something intercepting TLS between this server and the internet.
source_rate_limited (429) Graph is throttling. Wait; Graph’s own Retry-After is measured in seconds, not minutes.
source_unreachable (502) Could not reach login.microsoftonline.com or graph.microsoft.com.
source_error (502) Anything else, with Microsoft’s own words.

A missing or unconsented IMAP.AccessAsApp, or a mailbox the service principal was not given — a token that works for discovery but not for IMAP — is not an HTTP error, because discovery never touches IMAP. The connection test’s mailbox check reports it as auth, naming the permission and the service principal, and a run fails each such mailbox on auth.

An account with no Exchange Online mailbox (MailboxNotEnabledForRESTAPI, MailboxNotHostedInExchangeOnline) is not an error either: when mailbox settings are read, it is skipped silently, and never fails discovery for the tenant.

If discovery reports that settings could not be read for n mailboxes, the application is missing MailboxSettings.Read or its admin consent. Grant it, or turn the option off — the mail still copies.

Piece Where
Schemas packages/core/src/migration.ts (M365SourceConfig, M365SourceSecret)
Token minting and files packages/migration/src/connectors/oauth.ts
Graph calls packages/migration/src/connectors/m365-api.ts
Rule translation (pure) packages/migration/src/connectors/m365-rules.ts
The connector packages/migration/src/connectors/m365.ts
Token refresh mid-run apps/worker/src/jobs/migration.ts
Pre-create and import apps/api/src/services/migration-import.ts (shared with every source)
UI apps/admin/src/routes/migrations.tsx, migration.tsx