Email migration — copying mailboxes from one hosting provider to another — sounds simple until you try it. Do it carelessly and you lose mail, or worse, you lose mail without noticing. This guide is a complete beginner’s walkthrough of how to migrate email accounts between hosting providers safely, using imapsync, the standard open-source tool for the job.
No prior migration experience is assumed. Commands are written to be copied and pasted, with two kinds of placeholder you must replace: example.com hostnames and user@example.com addresses. Where a command depends on a script, the full script is included in this guide — nothing is left for you to invent.
A note on versions: imapsync changes, and some behaviour described here differs between releases. Check which version you have before you start:
imapsync --version
Anything version-sensitive is flagged as you reach it.
Key takeaways
Email migration copies a mailbox between servers over IMAP. It is a copy, not a move, so it stays reversible until you delete the source.
Always run
imapsync --dryfirst. It writes nothing and catches almost every configuration mistake for free.Leave
--useheaderat its defaults. The widely copied--useheader Message-Id --useheader Dateadvice replaces a good matching criterion with a worse one.“It finished without errors” is not verification. Check
Folders synced,not in host2,Messages void (noheader)and the skipped-message log lines.Switch MX records last, only after destination mailboxes exist and the data is verified.
Check your imapsync version before using OAuth. On 2.229 a token that expires mid-run was observed ending a run with
Folders synced: 0/0,Detected 0 errorsand a success exit code, having copied nothing. Mid-run token refresh arrived in 2.267.
What Is Email Migration?
Email migration is the process of copying a mailbox — its messages, folders, attachments and read/unread state — from one mail server to another, usually over IMAP, without deleting anything at the source.
It is a copy, not a move. The source mailbox keeps everything. That matters: it means a migration is reversible right up until you delete the source.
When You Need One
- Changing providers. Moving to cheaper, faster, or better-supported hosting.
- Leaving a bundled service. Email that came with a website package and no longer fits.
- Company changes. Mergers, rebrands, or a domain change.
- Consolidation. Several providers collapsing into one.
- Taking back control. Moving to a host you administer yourself.
What Migration Does Not Include
Migration copies mailbox data. It does not copy:
- Contacts and calendars (these use different protocols)
- Email filters and rules
- Aliases, forwarders, and distribution lists
- Out-of-office autoresponders
- Signatures stored in a webmail interface
Plan to recreate those separately. People forget this and are surprised when filters stop working.
Before You Start: Prerequisites
Skipping these causes most migration failures.
1. IMAP Must Be Enabled on Both Sides
IMAP is the protocol that lets a tool read a mailbox over the network. Without it, migration cannot happen.
- Source: enable IMAP access. Some providers disable it by default, or require an administrator to turn it on for the whole organisation.
- Destination: confirm IMAP is available and note the hostname, port, and encryption.
Standard settings are almost always:
IMAP over SSL/TLS port 993
Avoid port 143 unless you have a specific reason — it is unencrypted unless STARTTLS is negotiated.
imapsync will in fact pick encryption on its own: if port 993 is open it uses SSL, and if it has to fall back to port 143 it looks for TLS support in the server’s CAPABILITY list and uses STARTTLS. Passing --ssl1 --ssl2 forces the behaviour you want instead of leaving it to detection, which is why every example here does so.
2. You Need Working Credentials for Every Mailbox
You need to log in to each mailbox on both sides.
- Passwords. Simplest case: you have the password for each mailbox.
- App-specific passwords, or OAuth. What a provider accepts over IMAP varies, and you cannot infer it from whether two-factor authentication is on. Some providers issue app-specific passwords for exactly this case; some require OAuth and will not accept a password at all; some accept the account password once IMAP access is explicitly enabled. Check the provider’s own documentation for both sides before you assume which of the three applies.
- Admin-level access. Larger providers let an administrator authenticate as any user without knowing individual passwords. This is the cleanest route for bulk migrations and usually involves OAuth rather than a password.
3. Destination Mailboxes Must Already Exist
imapsync copies into a mailbox. It does not create accounts.
Create every destination mailbox first and confirm each one accepts a login before you transfer anything.
4. Know Your Data Volume
Check the size of each mailbox before starting. This drives everything: how long it takes, whether you hit rate limits, and whether it fits at the destination.
Budget for roughly twice the mailbox size in network traffic. imapsync holds no local copy of the mailbox: it downloads each message from the source and uploads it to the destination, so a 10 GB mailbox moves about 20 GB across the machine running the migration.
5. Install imapsync
Installation differs more than you would expect, because imapsync is not in the Debian or Ubuntu package archives. sudo apt install imapsync fails on a stock system. It was in Debian until 2011 and was removed; the project author has maintained a downloadable Debian package since 2024.
# Debian / Ubuntu — install the project's own package
cd /tmp
wget https://imapsync.lamiral.info/dist2/imapsync.deb
sudo apt install ./imapsync.deb
# RHEL / Rocky / AlmaLinux — available in EPEL
sudo dnf install epel-release
sudo dnf install imapsync
# Verify
imapsync --version
Check what version you got. Distribution packages lag a long way behind: the EPEL 9 package is 2.229, released November 2022, while the current upstream release is 2.314. That gap matters in exactly one place in this guide — OAuth token handling — and it is called out there. If you need current behaviour, install from the project’s own package or run the script directly from source:
git clone https://github.com/imapsync/imapsync.git
cd imapsync
./imapsync --version
Running from source needs a set of Perl modules. The project ships per-distribution instructions in its INSTALL.d/ directory that list them.
Step 1: Confirm Both Sides Are Reachable
Before running anything, prove each mailbox accepts a connection.
The tempting command here is openssl s_client, and it does work, but it opens an interactive session and sits there waiting for IMAP commands — on an unreachable host it simply hangs until you interrupt it, which is a confusing first experience. imapsync has purpose-built checks that are better in every way:
# Connect to both servers, print what they advertise, and exit
imapsync --host1 imap.example.com --host2 mail.example.com --justconnect
That tests reachability and encryption but not credentials. To test the logins too — which is what you actually want to know — use --justlogin:
imapsync \
--host1 imap.example.com --port1 993 --ssl1 \
--user1 user@example.com --passfile1 ./creds/source/user@example.com.pass \
--host2 mail.example.com --port2 993 --ssl2 \
--user2 user@example.com --passfile2 ./creds/dest/user@example.com.pass \
--justlogin
This logs in to both sides and exits. Run it for every mailbox before you transfer anything. Finding a bad password now costs seconds; finding it in the middle of a bulk run costs you a confusing log file.
Create the password files it refers to as described in the next section.
Step 2: Keep Passwords Out of the Command Line
Do this before the dry run, because the dry run already needs the password files.
Use --passfile1 and --passfile2 rather than --password1 and --password2. The project’s own security documentation is blunt about why: with --password1, any user on the machine can read the password out of the process list with ps auxwwww. Environment variables are no better — ps auxwwwwe shows those too. A file with mode 600 is the recommended option.
The catch is that the obvious way to create that file leaks the password anyway:
# Don't do this — the password is now in your shell history
printf '%s' 'the-source-password' > ./source.pass
Anything you type on a command line lands in your shell history file and is visible in the process list while it runs. The fix is to have the shell read the password without echoing it and without it ever becoming a command argument. Save this as make-passfile.sh:
#!/bin/bash
set -u
if [ $# -ne 2 ]; then
printf 'usage: %s <creds-dir> <user@example.com>\n' "$0" >&2
exit 2
fi
umask 077
install -d -m 700 "$1"
printf 'Password for %s: ' "$2" >&2
IFS= read -rs pw
printf '\n' >&2
printf '%s' "$pw" > "$1/$2.pass"
unset pw
chmod 600 "$1/$2.pass"
Then, for each mailbox:
chmod 700 make-passfile.sh
./make-passfile.sh ./creds/source user@example.com
./make-passfile.sh ./creds/dest user@example.com
You are prompted, nothing is echoed to the screen, nothing enters your shell history, and nothing appears in the process list. What each piece does:
umask 077means the file is created unreadable by anyone else from the very first byte, rather than being briefly world-readable and then fixed bychmod.install -d -m 700sets that mode on the directory you name; if you nest it inside another new directory, check the parent’s mode too.read -rsreads the line without echoing (-s) and without treating backslashes specially (-r), so a password containing\survives.IFS=stops leading or trailing spaces in the password from being stripped.printf '%s'writes exactly the characters with no trailing newline.
read -s is a bash feature, not POSIX sh, which is why the script begins #!/bin/bash. It works on Debian, Ubuntu, RHEL, Rocky, and AlmaLinux as shipped.
About That Trailing Newline
You will see advice that you must use printf rather than echo because echo appends a newline “that some servers reject”. For imapsync that reason is not correct: it reads only the first line of the file and strips the line ending, so a plain trailing newline is harmless.
The real hazard is CRLF line endings — a file created on Windows, or edited with a tool that writes \r\n. Stripping the newline leaves the carriage return behind, and that stray \r becomes part of the password imapsync sends. The result is an authentication failure that looks completely inexplicable, because the password looks right in every editor you open it in.
So printf '%s' is still the right choice — it writes no line ending at all and cannot have this problem. If you inherit a password file from somewhere else and logins fail, check it:
# A count greater than 0 means the file contains a carriage return
grep -c $'\r' ./creds/source/user@example.com.pass
# See the exact bytes, including any \r or \n
od -c ./creds/source/user@example.com.pass | head -3
Step 3: Always Run a Dry Run First
A dry run connects to both servers, lists folders, counts messages, and reports what would happen — without writing anything.
Never skip this. It is free and it catches almost every configuration mistake.
imapsync \
--host1 imap.example.com --port1 993 --ssl1 \
--user1 user@example.com --passfile1 ./creds/source/user@example.com.pass \
--host2 mail.example.com --port2 993 --ssl2 \
--user2 user@example.com --passfile2 ./creds/dest/user@example.com.pass \
--automap \
--dry
Reading the Dry Run Output
Several lines matter. Here are the ones to look at, as they actually appear:
Messages transferred : 0 (could be 4127 without --dry mode)
Messages skipped : 0
Messages void (noheader) on host1 : 0
Messages found in host1 not in host2 : 4127 messages
Messages found in host2 not in host1 : 0 messages
Detected 0 errors
not in host2= what will be copied. Compare it against what you expect.not in host1= mail already at the destination that is not at the source. On an empty new mailbox this should be zero.Messages void (noheader)= messagesimapsynccould not identify at all, because they have none of the headers it matches on. These need attention; see the note on--addheaderbelow.Detected 0 errorsmust be zero before you proceed.- In a dry run,
Messages transferredis always 0 and the real figure appears in the parenthetical.
You will also see the folder mapping:
Folders mapping from --automap feature (use --f1f2 to override any mapping):
[INBOX] -> [INBOX]
[Sent] -> [Sent]
[Drafts] -> [Drafts]
[Junk] -> [Junk]
[Trash] -> [Trash]
[Archive] -> [Archive]
and then, as the sync walks them:
Folder 1/6 [INBOX] -> [INBOX]
Folder 2/6 [Sent] -> [Sent]
Folder names are printed inside square brackets. If a name is stored in the IMAP server’s modified UTF-7 encoding, you get the readable form alongside it, as [&BBoEPgRABDcEOAQ9BDA-] = [Корзина], which makes non-English folders much easier to check.
Check this carefully. If Sent is mapping to something odd, fix it now — after the transfer it is far more annoying.
The dry run also prints the destination quota, which saves you a separate check. See the quota section below.
Step 4: Run the Real Migration
Remove --dry:
imapsync \
--host1 imap.example.com --port1 993 --ssl1 \
--user1 user@example.com --passfile1 ./creds/source/user@example.com.pass \
--host2 mail.example.com --port2 993 --ssl2 \
--user2 user@example.com --passfile2 ./creds/dest/user@example.com.pass \
--automap \
--timeout1 900 --timeout2 900 \
--logdir ./logs --logfile user-at-example-com.log
That is deliberately short. The flags most guides pile on here are either already the default or actively risky, and the next two sections explain which is which.
What Each Flag Does
| Flag | Purpose |
|---|---|
--automap | Matches special folders automatically, even when named differently or in another language |
--timeout1 / --timeout2 | Prevents a slow server from hanging the run |
--logdir / --logfile | Writes a full log. You will want this |
Flags That Only Restate the Default
These three appear in almost every migration guide. All three are already the default, and passing them changes nothing:
| Flag | Actual status |
|---|---|
--syncinternaldates | On by default. Original timestamps are preserved whether you ask or not |
--allowsizemismatch | On by default. Size differences between servers are already tolerated |
--noreleasecheck | Already the default in the GitHub source and in distribution packages built from it |
Passing them is harmless, and being explicit is a defensible habit. Just do not believe you have changed the tool’s behaviour, and do not let their presence convince you that a run is configured correctly.
Flags to Avoid
--delete2deletes mail at the destination that is not at the source. If any live mail has already arrived, this destroys it. Leave it off.--skipcrossduplicatesskips a message if it already exists in a different folder. On label-based systems where a message legitimately belongs to two folders, this drops real mail.
--maxsize is often listed alongside those two, with the warning that it skips oversized messages “silently”. That is not accurate, and the real behaviour is worth knowing because it can apply even when you do not use the flag. See the oversized-message section below.
How Messages Are Matched — Leave This Alone
imapsync avoids duplicates by comparing message headers. By default it identifies a message by Message-Id together with Received, and treats two messages as the same only when both match.
You will see guides recommend --useheader Message-Id --useheader Date. Do not do this. The option replaces the default set rather than adding to it, so that flag pair drops Received in favour of Date — and the project’s own documentation on duplicates says plainly that Date is a poor identifier, because some IMAP servers rewrite it. You would be trading a good discriminator for a worse one.
Three rules:
- Start with the defaults. Pass no
--useheaderat all. - Only change them if you see a real problem. The symptom is a second run re-copying messages it already copied, which produces duplicates. The documented remedy is
Message-Idon its own, notMessage-IdplusDate, because the usual cause is a server rewriting theReceivedlines:
# Test the change safely first — --dry writes nothing
imapsync ... --useheader "Message-Id" --dry
Look at Messages skipped in the output. If it is close to the number of messages already transferred, the new criterion is recognising them correctly and you can use it for real. If it is 0, the criterion is not working either — do not proceed.
- Never change the criterion part-way through a migration. Identity is defined by whatever headers you chose, so switching mid-flight makes
imapsyncfail to recognise everything it already copied and transfer the whole mailbox again as duplicates. Pick one approach and keep it for every run on that mailbox, including catch-ups.
If the dry run reported a non-zero Messages void (noheader) on host1, those messages have no usable identification headers — common in Sent and Drafts. --addheader handles them by synthesising a Message-Id from the source message’s IMAP UID. imapsync will suggest the flag itself in the statistics line when it detects the condition.
Step 5: Migrating Many Mailboxes
Put the mailbox addresses in a file, one per line, and give each one its own password files via the script from Step 2. Save this as migrate-one.sh:
#!/bin/bash
set -u
user="${1:?usage: migrate-one.sh user@example.com}"
mkdir -p ./logs
imapsync \
--host1 imap.example.com --port1 993 --ssl1 \
--user1 "$user" --passfile1 "./creds/source/$user.pass" \
--host2 mail.example.com --port2 993 --ssl2 \
--user2 "$user" --passfile2 "./creds/dest/$user.pass" \
--automap \
--timeout1 900 --timeout2 900 \
--logdir ./logs --logfile "$user.log"
Run them one at a time:
chmod 700 migrate-one.sh
while read -r user; do
[ -z "$user" ] && continue
printf '=== %s %s ===\n' "$user" "$(date)"
./migrate-one.sh "$user"
done < mailboxes.txt
Or several at once:
xargs -a mailboxes.txt -P 4 -I{} ./migrate-one.sh {}
-P 4 runs four mailboxes in parallel. Start low. Too much concurrency triggers rate limiting, and separate mailboxes usually have separate limits anyway.
Note that migrate-one.sh does not check its own exit status, and neither does the loop. imapsync exits non-zero when something went wrong, so for a large migration it is worth recording that:
while read -r user; do
[ -z "$user" ] && continue
if ./migrate-one.sh "$user"; then
printf '%s OK\n' "$user" >> ./results.txt
else
printf '%s FAILED rc=%s\n' "$user" "$?" >> ./results.txt
fi
done < mailboxes.txt
A non-zero exit tells you to look at that mailbox’s log. It is not by itself proof of data loss — and a zero exit is not proof of completeness either, which is what the next step is for.
Step 6: Verify the Migration Actually Completed
To verify an email migration, confirm four things: Folders synced matches the expected folder count and is not 0/0, Messages found in host1 not in host2 is 0, Messages void (noheader) is 0, and no log line contains exceeds maxsize limit.
This is the step people skip, and it is the one that matters.
“It finished without errors” is not verification. Confirm the data is there.
Level 1: Check the Summary
At the end of a successful run:
Messages transferred : 4127
Messages skipped : 0
Messages void (noheader) on host1 : 0
Messages found in host1 not in host2 : 0 messages
Messages deleted on host1 : 0
Messages deleted on host2 : 0
Reconnections to host1 : 0
Reconnections to host2 : 0
The sync looks good, all 4127 identified messages in host1 are on host2.
There is no unidentified message on host1.
Detected 0 errors
Read the plain-English verdict lines first — imapsync works out the conclusion for you, and they are far harder to misread than a column of numbers:
The sync looks good, all N identified messages in host1 are on host2.This is the line you want.There is no unidentified message on host1.The companion to it. If instead you see a count of unidentified messages, read the--addheadernote below.The sync is not strict, there are N among M identified messages in host2 that are not on host1.Expected on a destination that has started receiving live mail, and nothing to fix. It will suggest--delete2to make the sync strict — ignore that suggestion on a live mailbox, for the reasons in the flags section.
Then confirm the numbers. not in host2: 0 is what you want. Anything else means the job is incomplete — run it again, since transfers are incremental and resume where they stopped.
Three limits on what that zero proves, all worth understanding:
- It describes the comparison made during that run.
imapsyncscans each folder, compares, copies, and moves on. A message that arrives in a folder after it has been scanned is not in the count. On a live mailbox the figure is therefore a snapshot, not a standing guarantee — which is exactly why the cutover sequence later in this guide ends with catch-up runs. - It only counts messages it could identify. If
Messages void (noheader)is non-zero, those messages were never part of the comparison. Treat anot in host2: 0alongside a non-zeronoheadercount as incomplete. - It is scoped to the folders that ran. If a folder was excluded, or never matched, its contents are not in the total. Always read
Folders syncedalongside it. This is not a theoretical concern: a run that loses its session early can reportFolders synced: 0/0 synced,Messages transferred: 0,not in host2: 0 messagesandDetected 0 errors, then exit successfully — having done nothing at all. Every number says “fine” because nothing was ever compared.0/0is the tell.
Reconnections to host1/host2 is worth a glance, but do not read too much into it: a non-zero value means connections dropped, while zero does not prove none did. The counter only covers reconnections imapsync performs itself, not the ones the underlying Perl IMAP library performs transparently when a single command fails — those are invisible in the statistics.
Level 2: Compare Folder by Folder
Totals can match while individual folders are wrong. Count messages per folder on both sides:
imapsync \
--host1 imap.example.com --port1 993 --ssl1 \
--user1 user@example.com --passfile1 ./creds/source/user@example.com.pass \
--host2 mail.example.com --port2 993 --ssl2 \
--user2 user@example.com --passfile2 ./creds/dest/user@example.com.pass \
--automap --justfoldersizes
--justfoldersizes prints the per-folder message counts and sizes for both sides and then exits without syncing anything.
Level 3: Check Trash and Spam Explicitly
Most people verify the Inbox, glance at Sent, and call it done.
Check every folder, including Trash and Junk/Spam. Deleted items are frequently the ones someone needs six months later.
--automap matches special folders two ways: first by the SPECIAL-USE attributes a server advertises (\All, \Archive, \Drafts, \Flagged, \Junk, \Sent, \Trash), and failing that by name, against a built-in list that already includes common variants such as Deleted Items, Deleted Messages, Spam, and translated names in several languages. It handles far more than its one-line help suggests.
When a folder genuinely is not matched — a custom folder, or a name nobody anticipated — map it manually with --f1f2, giving full path folder names on both sides:
--f1f2 "Clients/Archive 2019=Archive/Clients-2019"
--f1f2 overrides --automap, maps exactly one folder to one folder, and can be repeated. For anything requiring a pattern rather than a single pair, --regextrans2 is the general-purpose option.
Two things to expect, both harmless:
- Spam counts often differ. Many providers auto-delete spam after 30 days. The destination copy may end up with more spam than the source, because the source kept deleting while the copy did not.
- Messages that moved look “missing”. If someone moves mail from Inbox to Trash after the copy, a per-folder comparison reports it missing from Inbox. Before panicking, check whether it exists somewhere else in the destination account. A whole-account check distinguishes a moved message from a lost one.
Level 4: Open the Mailbox
Log in to webmail at the destination. Look at folders. Open a message with an attachment and confirm it downloads. Automated checks can pass while something is subtly wrong.
Common Email Migration Problems and How to Fix Them
OAuth Token Expiry and Authentication Failures
Symptom: the run starts fine, then dies partway with AUTHENTICATIONFAILED or a session-expired error.
Cause: if your provider uses OAuth tokens rather than passwords, those tokens are short-lived. Providers issue an expires_in value with each token and commonly set it to 3600 seconds — one hour. Treat the value the provider returns as authoritative rather than assuming an hour.
What imapsync does about it depends on your version, and this is the one place in this guide where the difference really matters.
Pass the token as a file path, not as the token string itself:
--oauthaccesstoken1 ./tokens/user@example.com.tok
On current versions, imapsync re-reads that file every time it authenticates, including on a reconnect part-way through a run. So if a background process keeps the file topped up with a fresh token, a long run survives token expiry. Current versions also let imapsync do the refreshing itself:
--oauthaccesstoken1 ./tokens/user@example.com.tok \
--oauthrefreshcmd1 "./mint-token.sh user@example.com ./tokens/user@example.com.tok"
--oauthrefreshcmd1 and --oauthrefreshcmd2 run the command you give them before each authentication, so the token is refreshed exactly when it is needed. They were added in release 2.267, in October 2023.
On older versions — including the one in EPEL — neither of those is true. Up to and including 2.229 the token file is read once, at login, and the resulting credential is cached for the life of the process. Rewriting the file does nothing for a run already in progress, and --oauthrefreshcmd does not exist — the option is rejected outright with Unknown option: oauthrefreshcmd1. If imapsync --version reports 2.229 or anything earlier than 2.267, you must keep each run short enough to finish inside the token’s lifetime.
Why an Expired OAuth Token Can Fake a Successful Migration
On 2.229, when a session drops mid-run and the cached token has since expired, the re-authentication fails — but the run does not necessarily stop with an error. It can finish like this:
Folders synced : 0/0 synced
Messages transferred : 0
Messages found in host1 not in host2 : 0 messages
Detected 0 errors
Exiting with return value 0 (EX_OK: successful termination)
Zero errors, successful exit, and not in host2: 0 — the figure this guide tells you to look for. Nothing was transferred and nothing was even compared. Folders synced: 0/0 is the only thing in that block that gives it away. If you are running OAuth on a pre-2.267 build, check the folder count on every single run, and never accept the exit code as proof of completion.
Here is a refresher script to go with either approach. Replace the body with whatever your provider’s token endpoint requires; this is the shape, not a working provider integration:
#!/bin/bash
set -u
user="${1:?usage: mint-token.sh user@example.com [outfile]}"
out="${2:-}"
umask 077
# Replace this line with the token request your provider documents.
# It must print the access token, and nothing else, on stdout.
token=$(your-provider-token-command "$user") || exit 1
if [ -n "$out" ]; then
printf '%s' "$token" > "$out"
chmod 600 "$out"
else
printf '%s' "$token"
fi
And here is the bounded-rounds wrapper for older versions, which keeps every run comfortably inside a one-hour token:
#!/bin/bash
set -u
user="${1:?usage: migrate-oauth.sh user@example.com [expected-folder-count]}"
expected="${2:-}"
mkdir -p ./logs ./tokens
for round in $(seq 1 12); do
./mint-token.sh "$user" "./tokens/$user.tok" || exit 1
log="./logs/$user.r$round.log"
timeout 2700 imapsync \
--host1 imap.example.com --port1 993 --ssl1 \
--user1 "$user" --oauthaccesstoken1 "./tokens/$user.tok" \
--passfile1 /dev/null \
--host2 mail.example.com --port2 993 --ssl2 \
--user2 "$user" --passfile2 "./creds/dest/$user.pass" \
--automap \
--logdir ./logs --logfile "$user.r$round.log"
rc=$?
if [ ! -s "$log" ]; then
printf 'round %s: rc=%s but no log was written\n' "$round" "$rc"
continue
fi
synced=$(sed -n 's/^Folders synced *: *\([0-9][0-9]*\)\/[0-9][0-9]*.*/\1/p' "$log" | tail -1)
total=$(sed -n 's/^Folders synced *: *[0-9][0-9]*\/\([0-9][0-9]*\).*/\1/p' "$log" | tail -1)
left=$(sed -n 's/^Messages found in host1 not in host2 *: *\([0-9][0-9]*\).*/\1/p' "$log" | tail -1)
noheader=$(sed -n 's/^Messages void (noheader) on host1 *: *\([0-9][0-9]*\).*/\1/p' "$log" | tail -1)
oversize=$(grep -c 'exceeds maxsize limit' "$log")
verdict=$(grep -c 'The sync looks good' "$log")
printf 'round %s: rc=%s folders=%s/%s remaining=%s noheader=%s oversize=%s verdict=%s\n' \
"$round" "$rc" "${synced:-?}" "${total:-?}" "${left:-?}" "${noheader:-?}" \
"$oversize" "$verdict"
# Every condition has to hold. A remaining count of zero proves nothing on
# its own: a run that lost its session compared nothing and so reports zero.
if [ "$rc" -ne 0 ]; then
printf ' incomplete: imapsync exited %s\n' "$rc"
elif [ -z "$total" ]; then
printf ' incomplete: no "Folders synced" line, the run never reached its summary\n'
elif [ "$total" = "0" ]; then
printf ' incomplete: Folders synced 0/0, nothing was compared\n'
elif [ "$synced" != "$total" ]; then
printf ' incomplete: %s of %s folders synced\n' "$synced" "$total"
elif [ -n "$expected" ] && [ "$total" != "$expected" ]; then
printf ' incomplete: expected %s folders, this run saw %s\n' "$expected" "$total"
elif [ "${left:-1}" != "0" ]; then
printf ' incomplete: %s messages still only on the source\n' "${left:-unknown}"
elif [ "${noheader:-1}" != "0" ]; then
printf ' incomplete: %s unidentified messages were never compared\n' "${noheader:-unknown}"
elif [ "$oversize" -ne 0 ]; then
printf ' incomplete: %s messages skipped for exceeding the size limit\n' "$oversize"
elif [ "$verdict" -eq 0 ]; then
printf ' incomplete: imapsync did not print its own success verdict\n'
else
printf 'COMPLETE: %s/%s folders, 0 remaining, 0 unidentified, 0 oversized, verdict confirmed\n' \
"$synced" "$total"
exit 0
fi
done
printf 'NOT COMPLETE after %s rounds, read ./logs/%s.r*.log\n' "$round" "$user" >&2
exit 1
timeout 2700 caps each round at 45 minutes. Because transfers are incremental, each round resumes where the last stopped, and a round that hits the cap is not a failure. The --passfile1 /dev/null is there because older releases require a password argument alongside --oauthaccesstoken1 even though they ignore it; the project describes that requirement as a bug, and current versions no longer have it.
The completion test is deliberately strict, and every clause earns its place. The failure described above — token expires, session drops, run exits 0 having compared nothing — satisfies both of the obvious tests: the exit code is zero and the remaining count is zero. Only Folders synced: 0/0 and the absent success verdict give it away. So the script refuses to declare completion unless the run exited cleanly, reported a folder summary, synced every folder it found, found a non-zero number of folders, left nothing on the source, identified every message, skipped nothing for size, and printed its own verdict. Pass the expected folder count as a second argument and it will also refuse a run that silently saw fewer folders than you know the mailbox has.
Bounded rounds remain a reasonable technique on any version — they bound the damage from any single failure, and they make progress visible. Just be clear about why you are using them. On current imapsync it is belt-and-braces; on 2.229 it is the only thing standing between you and a failed run.
Other authentication causes:
- Two-factor authentication is on and you need an app-specific password
- IMAP is disabled for that account
- The password file has CRLF line endings (see Step 2)
- The password contains characters your shell mangled — which is exactly why password files are safer
Rate Limits and Bandwidth Caps
Symptom: transfer slows dramatically, stalls, or the server starts refusing connections.
Cause: many providers cap how much data a single account can download per day over IMAP. Limits are commonly enforced per mailbox, not per domain. Remember the traffic is about twice the mailbox size, since every message is downloaded and then uploaded.
Fixes:
- Check your provider’s documented limit before starting
- Spread large mailboxes across several days — incremental runs resume cleanly
- Run different mailboxes in parallel rather than hammering one; separate accounts usually have separate quotas
- Reduce concurrency if you see connection errors
- Throttle deliberately rather than being throttled:
--maxmessagespersecondand--maxbytespersecondcap the rate from your side
For a genuinely huge mailbox, the approach the project documents is to split the sync by date rather than by time:
imapsync ... --search "SENTSINCE 1-Jan-2023 SENTBEFORE 1-Jan-2024"
imapsync ... --search "SENTSINCE 1-Jan-2024 SENTBEFORE 1-Jan-2025"
Mind the boundary. SENTSINCE is inclusive and SENTBEFORE is exclusive — the IMAP standard defines them as “within or later than” and “earlier than” the given date. So the upper bound must be 1 January of the following year. Writing SENTBEFORE 31-Dec-2023 for a 2023 chunk silently omits every message dated 31 December, and if you build each chunk that way you lose one day per year with no error and no warning. The project’s own FAQ shows the 31-Dec form, so this is a case where copying the documented example costs you mail.
Note also what these keys match: SENTSINCE and SENTBEFORE test the message’s Date header, not the date it arrived in the mailbox. The arrival-time equivalents are SINCE and BEFORE, which test the server’s internal date. A message sent on 31 December and delivered on 2 January falls in the 2023 chunk by SENTSINCE/SENTBEFORE and the 2024 chunk by SINCE/BEFORE. Pick one pair and use it for every chunk, or a message can land in both or neither.
Each chunk is a complete, resumable run over one slice of the mailbox.
If you cannot tell whether a long-running job is working or wedged, --debugimap shows the live IMAP conversation — very verbose, but it answers the question. --pidfile writes the process ID somewhere you choose so you can point monitoring tools at it.
Destination Storage Quota Exceeded
Symptom: EXIT_OVERQUOTA, or a warning that the destination mailbox is full.
Cause: the destination mailbox has a smaller quota than the data you are moving. This is easy to miss because quotas are often set per mailbox, and a default tier gets applied to everyone.
You do not need a special command for this. If the destination advertises the IMAP QUOTA capability, imapsync checks it automatically at the start of every run, including a --dry run, and reports:
Host2: QUOTA capability found, presented in raw IMAP on next lines
Host2: Quota current storage is 643547136 bytes. Limit is 1073741824 bytes. So 59.94 % full
If the destination has no QUOTA capability you will see Host2: No QUOTA capability found, skipping it. instead, and you will have to check the quota through the provider’s control panel.
There is no --justquota option — if you have seen that suggested, it does not exist in any current release and imapsync will reject it with Unknown option: justquota. To check quotas without transferring anything, use the dry run from Step 3, or the --justfoldersizes command from Step 6; both print the quota line before doing their own work.
Compare the limit against the source mailbox size. If the source is 4 GB and the destination quota is 1 GB, raise the quota first.
Also watch mailboxes that land above 90% full. That threshold is not arbitrary: imapsync treats a destination above 90% as an error condition and says so —
Host2: 93.70 % full: it is time to find a bigger place!
— which means it also increments the error count, so a run can report Detected 1 errors for nothing worse than a nearly-full destination. Check usage after migrating, not just before: those mailboxes completed, but with live mail arriving they will start rejecting messages soon.
Messages Too Big for the Destination
This is the --maxsize story, and it matters even if you never type the flag.
Many servers advertise a maximum message size in their IMAP CAPABILITY response, as APPENDLIMIT. When the destination does that, imapsync reads it and sets --maxsize for you, unless you have already set a smaller value yourself. So messages can be skipped for size on a run where you never asked for any size limit.
It is not silent about it. Each skipped message produces a log line:
msg INBOX/4821 skipped (42134028 exceeds maxsize limit 35651584 bytes)
and they are counted in the statistics:
Messages skipped : 3
So the risk is not secret data loss, it is unread log files. Grep for it after any migration:
grep -c 'exceeds maxsize limit' ./logs/user@example.com.log
If that is non-zero, those messages are still on the source and did not reach the destination. Your options are to raise the destination’s message size limit, if you administer it, or to handle those messages separately. --truncmess can shorten them to fit, at the cost of truncating the message — a last resort, not a default.
Mail Hidden Outside Folders
On providers that use labels instead of folders, a message that has been archived may belong to no folder at all. A folder-by-folder sync cannot see it, so it is silently left behind.
If your source works this way, run an additional pass targeting archived mail specifically, using whatever search syntax the provider supports via --search. Check your source provider’s documentation for how archived or unlabelled mail is exposed over IMAP — and verify the resulting message count, because this is one of the easiest ways to lose a large amount of mail without any error appearing.
Bounced Mail from Switching MX Too Early
This is the most damaging mistake, and it is entirely avoidable.
MX records tell the internet where to deliver mail for your domain. If you point them at the new host before mailboxes exist there, incoming mail arrives at a server with nowhere to put it.
What happens next depends on how that server answers, and the outcome is genuinely different in each case:
- It rejects with a permanent error (5xx). The sending server stops trying and returns the message to the sender with a delivery failure notice. You never receive it, but the sender knows it failed and can resend later. Bad, not silent.
- It rejects with a temporary error (4xx). The sending server queues the message and retries. The standard governing this expects senders to keep trying for at least four to five days, with retries no closer together than thirty minutes. Mail queued this way usually does arrive once the mailbox exists. This is the best of the bad outcomes.
- It accepts the message and then cannot deliver it. This is the dangerous one. The sender has been told delivery succeeded, so there will be no retry. Whether you ever see the message depends entirely on what that server does with undeliverable mail.
Two caveats. Not every sending system follows the retry guidance — some give up in hours. And a failure notice is only useful if it reaches a human who acts on it; notices sent to automated addresses routinely vanish.
So the honest summary is: switching MX early will cost you mail, and how much depends on configuration you do not control. Do not do it.
Prevention: never switch MX until destination mailboxes exist and accept mail. See the order of operations below.
Also lower your DNS TTL in advance. A day or two before the switch, reduce the MX record TTL to 300 seconds. Otherwise resolvers cache the old record for hours and mail keeps arriving at the old host long after you have moved on.
Two details people get wrong here. The TTL change itself has to propagate, so make it at least 24 hours before the cutover, not an hour before — the old TTL still governs how long resolvers hold the old record. And if you cannot get the TTL below a few hours, it is not a blocker: mail delivered to the old host after the switch is still sitting in the old mailbox, and a catch-up run collects it.
The Safe Order of Operations
Follow this sequence. Each step protects the one after it.
1. Prepare the Destination
Create every mailbox. Set quotas large enough for the data plus growth. Confirm each one logs in over IMAP with --justlogin.
2. Test With One Mailbox
Migrate a single small account end to end. Verify it fully. You will find your configuration mistakes here, cheaply.
3. Migrate the Data
Run the full migration with MX still pointing at the old host. Mail continues arriving normally throughout — users are not affected, and there is no deadline pressure.
4. Verify
Check counts, folders, Trash, Spam. Fix anything wrong. Do not proceed until verification passes.
5. Lower the DNS TTL
Reduce the MX TTL to 300 seconds, at least 24 hours ahead so the change itself propagates.
6. Run a Final Catch-Up
Between your migration and the cutover, new mail arrived at the old host. Run the same command again — it is incremental and copies only what is new, usually in minutes. Use the same --useheader settings as every previous run.
7. Switch MX Records
Now point MX at the new host. New mail begins arriving at the destination.
8. Run One More Catch-Up
DNS propagation is not instant. For a few hours, some senders still deliver to the old host. Run the sync again the next day to sweep up stragglers.
9. Keep the Old Mailboxes
Do not cancel the old service immediately. Keep it for at least 30 days. It costs little and it is your only safety net if something turns up missing.
How Long Does an Email Migration Take?
There is no reliable answer in hours: two migrations of identical size can differ by a factor of ten. As a rough order of magnitude, a few mailboxes under 5 GB may take an hour or two and ten to twenty mailboxes totalling 10–50 GB may take one to two days, but the limiting factor is almost always the source provider’s rate limits rather than network speed. Measure one representative mailbox and scale from its reported message rate.
These are rough planning figures, not predictions. Actual duration is driven by mailbox size, the source provider’s rate limits, message count (many small messages are slower per gigabyte than a few large ones), network throughput, and how much concurrency the source tolerates.
| Mailboxes | Data | Rough order of magnitude |
|---|---|---|
| 1–5 | Under 5 GB | An hour or two |
| 10–20 | 10–50 GB | One to two days |
| 30+ | 50 GB+ | Several days, driven by provider rate limits |
Measure rather than guess: migrate one representative mailbox, note the Message rate and Average bandwidth rate lines in its statistics, and scale from there.
Transfer speed is rarely your bottleneck. Provider rate limits are. Plan in days, not hours, for anything large.
Email Migration Checklist
Before
imapsync --versionrecorded, and OAuth behaviour checked against it (2.267+ for mid-run token refresh)- IMAP enabled on both sides
- Credentials tested for every mailbox with
--justlogin - Password files mode 600, no CRLF
- Destination mailboxes created and logging in
- Destination quotas larger than source data
- Source mailbox sizes measured, bandwidth budgeted at 2× that
- Provider rate limits checked
During
- Dry run first, zero errors
- Folder mapping reviewed
- Passwords in files, not on the command line
--useheaderleft at defaults, or chosen once and never changed- Logs being written
After
Folders syncedmatches the expected folder count — not0/0not in host2: 0for every mailboxMessages void (noheader): 0for every mailbox- No
exceeds maxsize limitlines in any log - Folder counts compared with
--justfoldersizes - Trash and Spam confirmed present
- Destination quota usage checked, nothing near 90%
- Mailbox opened and an attachment checked
- DNS TTL lowered at least 24 hours before cutover
- Final catch-up run
- MX switched only after verification
- Follow-up catch-up the next day
- Old mailboxes retained 30 days
Frequently Asked Questions
Does migration delete anything from the old host?
No. imapsync copies. The source is untouched unless you explicitly use deletion flags, which you should not.
Can I run it more than once?
Yes, and you should. It is incremental: already-copied messages are skipped and only new mail transfers. This is what makes catch-up runs cheap. Keep the matching options identical between runs.
Will users be disrupted?
Not during the copy — they keep using the old mailbox normally. The only real transition is the MX switch.
What happens to mail arriving mid-migration?
It lands at whichever host MX currently points to. That is why the final catch-up before cutover matters.
Do contacts and calendars come across?
No. Those use different protocols and must be exported and imported separately.
What if a mailbox is enormous?
It still works, it just takes longer and may span multiple days because of rate limits. Split it by date with --search and let each chunk resume.
Why does my run report errors when nothing failed?
Check whether the destination is above 90% full. That alone counts as an error. Read the error lines rather than the count.
Do I need to install the newest version?
Only if you are using OAuth. For password-based migrations the distribution package is fine. For OAuth, a version before 2.267 cannot refresh a token mid-run, and a token that expires part-way through can end the run reporting success while having synced nothing. That is worth an upgrade.
The Four Things That Matter Most
Email migration is not difficult, but it is unforgiving of shortcuts. The four things that matter most:
- Dry run first. Catches configuration errors for free.
- Leave the matching defaults alone. Most duplicate disasters start with someone changing
--useheaderbecause a guide told them to. - Verify properly. Folder counts, Trash and Spam, noheader count, skipped-message log lines — not just “it finished”.
- Switch MX last. Only after destination mailboxes exist and the data is verified.
Get those right and migration is routine. Skip them and you find out what was missing months later, when someone needs an email that is no longer anywhere.
Sources
Every version-specific claim in this guide was checked against the tool’s own documentation and source, or reproduced under test. The primary sources:
- imapsync source and documentation — https://github.com/imapsync/imapsync
- Duplicates and message-matching behaviour, including why
Dateis a poor identifier — https://github.com/imapsync/imapsync/blob/master/FAQ.d/FAQ.Duplicates.txt APPENDLIMITand automatic--maxsize— https://github.com/imapsync/imapsync/blob/master/FAQ.d/FAQ.Messages_Too_Big.txt- OAuth 2.0 / XOAUTH2 handling — https://github.com/imapsync/imapsync/blob/master/FAQ.d/FAQ.XOAUTH2.txt
- Password handling, and why a password on the command line is readable by any user on the machine — the
SECURITYsection of the imapsync script’s own documentation — https://github.com/imapsync/imapsync/blob/master/imapsync - Bandwidth, large mailboxes and DNS TTL — https://github.com/imapsync/imapsync/blob/master/FAQ.d/FAQ.Bandwidth.txt, https://github.com/imapsync/imapsync/blob/master/FAQ.d/FAQ.Big_Mailbox.txt, https://github.com/imapsync/imapsync/blob/master/FAQ.d/FAQ.TTL.txt
- The release that introduced
--oauthrefreshcmd1/--oauthrefreshcmd2(2.266 added it as--oauthrefreshscript, 2.267 renamed it) — https://github.com/imapsync/imapsync/blob/master/ChangeLog - Sending-server retry behaviour on temporary and permanent rejections — RFC 5321, sections 4.2.1 and 4.5.4.1 — https://www.rfc-editor.org/rfc/rfc5321.txt
- Debian and Ubuntu package availability — https://packages.debian.org/search?keywords=imapsync&searchon=names&suite=all§ion=all
