Skip to content

Repository files navigation

exchange_to_imap

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.

Scope and limitations

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.
  • \Answered is 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.

Requirements

Python 3.10 or newer.

pip install -r requirements.txt

Setting up Graph access

The 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:

  1. In the Entra admin center, go to Identity > Applications > App registrations > New registration.
  2. Give it any name, choose Accounts in this organizational directory only, and leave the redirect URI empty. Register it.
  3. On the Overview page, copy the Application (client) ID and the Directory (tenant) ID into config.ini.
  4. Under Authentication > Advanced settings, set Allow public client flows to Yes and save. The device-code flow does not work without this.
  5. Under API permissions, add Microsoft Graph > Delegated permissions > Mail.Read. Delegated Mail.Read normally 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.

Quick start

  1. Copy config.ini.example to config.ini and fill it in. If you put the IMAP password in the file, restrict it: chmod 600 config.ini. Alternatively leave password empty and set the IMAP_PASSWORD environment variable.

  2. Check what the destination server already has, and what separator it uses:

    python list_imap_folders.py config.ini
  3. 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
  4. Run the migration:

    python exchange_to_imap.py --config config.ini
  5. 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.

Command-line options

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.

Copying specific folders

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 file reference

config.ini.example is a fully commented template.

[general]

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

[exchange]

Key Required Description
client_id yes Application (client) ID of the app registration
tenant_id yes Directory (tenant) ID of the app registration

[imap]

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

Folder mapping

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.

State database

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.

Troubleshooting

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.

Tests

The pure functions (folder mapping, flag mapping, date formatting, LIST parsing) have unit tests that need no network access:

python -m pytest tests/

License

MIT. See LICENSE.

About

Migrate Microsoft 365 / Exchange mailboxes to any IMAP server

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages