Copy a Microsoft 365 / Exchange Online mailbox to any IMAP server.
The source mailbox is read through the Microsoft Graph API, using the MSAL
device-code sign-in flow and the read-only Mail.Read scope. The destination is
plain IMAP4 over TLS. Nothing is ever written to or deleted from the source.
- Resumable - every message copied is recorded in a local SQLite database, so re-running the same command picks up where it left off.
- Full folder hierarchy - folders and sub-folders are recreated on the destination, with well-known folders mapped to their IMAP equivalents.
- Flags preserved - read/unread, flagged and draft status carry across.
- Survives long runs - the Graph token is refreshed automatically, throttling (HTTP 429) is honoured with backoff, and a dropped IMAP socket is reconnected.
Worth knowing before you start:
- One mailbox per run, one direction only (Exchange to IMAP). Run it once per mailbox, with a separate config and state database for each.
- Requires Exchange Online / Microsoft 365. It does not work against on-premises Exchange without Graph access.
- Duplicate detection is based on the state database, not on the contents of the destination. If you delete or lose the state database and run again, messages are copied a second time.
- Messages are keyed by their Graph message id, which is per-mailbox and changes if a message is moved between folders. A message moved in the source after being copied may be copied again into its new folder.
\Answeredis not set: Graph does not expose it as an ordinary property.- Messages larger than 25 MB are skipped and reported as errors.
- Calendar, contacts and tasks are not touched. Mail only.
Python 3.10 or newer.
pip install -r requirements.txtThe script signs in as a normal user with a device code, so it needs an app registration in the tenant. This is a one-off, five-minute job:
- In the Entra admin center, go to Identity > Applications > App registrations > New registration.
- Give it any name, choose Accounts in this organizational directory only, and leave the redirect URI empty. Register it.
- On the Overview page, copy the Application (client) ID and the
Directory (tenant) ID into
config.ini. - Under Authentication > Advanced settings, set Allow public client flows to Yes and save. The device-code flow does not work without this.
- Under API permissions, add Microsoft Graph > Delegated permissions >
Mail.Read. Delegated
Mail.Readnormally needs no admin consent; if your tenant requires it, ask an admin to grant it.
You sign in as the owner of the mailbox, so no admin rights are needed to read your own mail.
-
Copy
config.ini.exampletoconfig.iniand fill it in. If you put the IMAP password in the file, restrict it:chmod 600 config.ini. Alternatively leavepasswordempty and set theIMAP_PASSWORDenvironment variable. -
Check what the destination server already has, and what separator it uses:
python list_imap_folders.py config.ini
-
Do a dry run. This verifies sign-in and IMAP login, and prints the folder mapping it would use, without writing anything:
python exchange_to_imap.py --config config.ini --dry-run
-
Run the migration:
python exchange_to_imap.py --config config.ini
-
If it stops part-way, or finishes with errors, run the same command again. Messages already copied are skipped.
On the first run you are shown a URL and a one-time code; sign in with them in a browser. The token is cached, so later runs need no interaction.
| Option | Description |
|---|---|
--config PATH |
Path to your config file (required) |
--dry-run |
Report what would be copied, writing nothing to IMAP or to the state database |
--folder NAME |
Copy only this folder (repeatable). Default: all folders |
--reauth |
Discard the cached token and sign in again |
--verbose |
Debug logging |
--version |
Print the version and exit |
Exit codes: 0 success, 1 fatal error, 2 finished but some messages failed,
130 interrupted.
python exchange_to_imap.py --config config.ini --folder "Inbox" --folder "Sent Items"Matching is case-insensitive and matches on substrings of the folder path, so
--folder Inbox also selects Inbox/Subfolder.
config.ini.example is a fully commented template.
| Key | Default | Description |
|---|---|---|
state_db |
migration_state.db |
SQLite file recording what has been copied |
token_cache |
msal_token_cache.bin |
Cached MSAL token. Contains a refresh token - treat it as a secret |
| Key | Required | Description |
|---|---|---|
client_id |
yes | Application (client) ID of the app registration |
tenant_id |
yes | Directory (tenant) ID of the app registration |
| Key | Required | Description |
|---|---|---|
host |
yes | IMAP server hostname |
port |
no | Default 993 |
username |
yes | IMAP login username |
password |
no | IMAP password. If omitted, IMAP_PASSWORD is used |
folder_prefix |
no | Prefix for destination folders, e.g. INBOX. Default: none |
ssl |
no | Default true. Set false for STARTTLS on port 143 |
verify_ssl |
no | Default true. Set false only when connecting by IP address |
Well-known Exchange folders are renamed to their usual IMAP equivalents, and resolved by Graph folder id rather than display name, so this works on non-English mailboxes too:
| Exchange | Destination |
|---|---|
| Inbox | INBOX |
| Sent Items | Sent |
| Drafts | Drafts |
| Deleted Items | Trash |
| Junk Email | Junk |
| Archive | Archive |
| Outbox, Conversation History, RSS Feeds, Sync Issues, Recoverable Items | not copied |
Any other folder keeps its own name. Sub-folders are joined with whatever
hierarchy separator the server reports (commonly . or /).
Some hosts require every folder to sit under INBOX. For those, set
folder_prefix = INBOX, which produces INBOX.Sent, INBOX.Trash and so on.
Check with list_imap_folders.py if you are not sure which style your server
uses. INBOX itself is never prefixed.
To change the mapping, edit FOLDER_NAME_MAP near the top of the script. A value
of None means the folder is skipped.
The state database is plain SQLite and can be inspected with any SQLite tool:
sqlite3 migration_state.db "SELECT folder_path, count(*) FROM migrated GROUP BY folder_path;"The run_log table records each run with its start and finish time, status, and
message counts.
To start over, delete the file - but note that the destination is not cleaned up, so a fresh run will duplicate anything already copied.
Sign-in fails with AADSTS7000218 or the device flow will not start
"Allow public client flows" is not enabled on the app registration. See step 4 above.
Could not refresh the Graph token
The cached refresh token has expired or been revoked. Run once with --reauth.
Graph returns 429 repeatedly Exchange Online is throttling you, which is normal for a large mailbox. The script waits for the interval the server asks for and carries on. If it gives up after several attempts, wait a while and re-run.
Could not create IMAP folder
The server rejected the name. Usually the folder namespace is different from what
was assumed - check list_imap_folders.py output and set folder_prefix.
IMAP append rejected for large messages Many servers cap individual message size. These are logged, counted as errors, and the rest of the migration continues.
Certificate errors against the IMAP server
Setting verify_ssl = false will get you connected, but it removes protection
against interception. Prefer connecting by hostname with a valid certificate.
The pure functions (folder mapping, flag mapping, date formatting, LIST parsing) have unit tests that need no network access:
python -m pytest tests/MIT. See LICENSE.