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.
Two tokens, two resources
Section titled “Two tokens, two resources”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.
Setting it up
Section titled “Setting it up”In the customer’s Entra admin centre (entra.microsoft.com), as a Global Administrator:
-
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.
-
Note the Application (client) ID and the Directory (tenant) ID.
-
Certificates & secrets → New client secret. Copy the value immediately; Entra never shows it again.
-
API permissions → Add a permission → Microsoft Graph → Application permissions, then add:
User.Read.AllGroup.Read.AllMailboxSettings.Read (only for "Read each mailbox's settings") -
Add a permission → APIs my organization uses → Office 365 Exchange Online → Application permissions, then add:
IMAP.AccessAsApp -
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.
-
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.
-
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 ExchangeOnlineManagementConnect-ExchangeOnline -Organization <tenant ID>New-ServicePrincipal -AppId <application (client) ID> -ObjectId <object ID>Add-MailboxPermission -Identity "someone@example.com" -User <service principal ID> -AccessRights FullAccessThe
-ObjectIdis 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 | flshows the identifier to pass as-User.Add-MailboxPermissionis 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.
Running one
Section titled “Running one”- 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.
- Add the domains on the Domains page for any that do not exist here yet.
- Read the source on the job’s Discover step.
- 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. - Import the aliases, automatic replies and inbox rules.
- From here it is an ordinary migration: Migrating a domain and cutting over.
How each thing is translated
Section titled “How each thing is translated”Mailboxes
Section titled “Mailboxes”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.
Proxy addresses → aliases
Section titled “Proxy addresses → aliases”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.
Groups → aliases
Section titled “Groups → aliases”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.
Automatic replies → Sieve vacation
Section titled “Automatic replies → Sieve vacation”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 (
externalAudienceother thannone, 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.
Inbox rules → filter rules
Section titled “Inbox rules → filter rules”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
deletemoves a message to Deleted Items and is recoverable;permanentDeleteis the one that destroys it. Sodeletebecomesfileinto "Trash"and onlypermanentDeletebecomesdiscard. Mapping the first ontodiscardwould 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.
Long mailboxes and expiring tokens
Section titled “Long mailboxes and expiring tokens”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.
Errors specific to this connector
Section titled “Errors specific to this connector”| 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.
Where it lives
Section titled “Where it lives”| 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 |