Practical Email Migrations With Imapsync
Any sysadmin will eventually have to deal with the pain of migrating email accounts from one service to another. Some providers make that process relatively straightforward with built-in tooling, while others leave you to figure it out yourself. And when you are running or migrating self-hosted mail servers, things can get complicated very quickly.
Luckily, there is a very useful tool for this exact job: imapsync. As the name suggests, it synchronizes mailboxes between two IMAP accounts. It works very well for one-off migrations, but where it really shines is in bulk account moves, especially when you want to reduce disruption for users and keep the process predictable.
This post shows a simple way to migrate multiple mailboxes using imapsync and a small shell script.
What imapsync is good for
imapsync is excellent for migrating mailbox contents between two accounts that both expose IMAP access. In practice, that makes it useful for moves such as:
- one hosted provider to another,
- one self-hosted mail server to another,
- or even migrations involving providers like Gmail, so long as IMAP access is available and properly configured.
It is important to be precise about scope, though: this is a mailbox migration tool, not a complete mail platform migration tool.
What it will typically migrate well:
- folders,
- emails,
- read/unread states,
- and most mailbox structure.
What it will not necessarily migrate:
- contacts,
- calendars,
- identities and signatures,
- client-side rules,
- server-side filters,
- aliases and forwarding rules,
- shared mailbox permissions,
- quotas and provider-specific settings.
That is not a flaw in imapsync; it is simply the difference between migrating mail data and migrating an entire mail environment.
Other tools exist, like vdirsyncer capable of migrating contacts and calendars, that can be used in a similar way to the script below. ManageSieve can migrate server-side inbox rules where Sieve is exposed, and provider admin CLI/API scripts can handle aliases, forwarding, permissions and quotas. Other settings live on the client side, and thus can’t be as easily transfered.
Preparing the account list
First, define the accounts you want to migrate in a .csv file. For example, mail_accounts.csv:
1
2
3
email,old_pass,new_pass
fake_mail_1@domain.com,randompass,notsorandompass
fake_mail_2@domain.com,passthatisrandom,passthatisnotrandom
This is intentionally simple, and it works well for controlled migrations.
That said, there is an obvious tradeoff here: this approach stores credentials in plaintext. For a temporary migration in a trusted admin environment, with temporary passwords, that may be acceptable, but in more sensitive environments you should prefer a secrets manager, encrypted files, or some other safer credential-handling workflow.
Bulk syncing with a shell script
With the account list ready, we can use imapsync together with a bit of shell scripting to process multiple mailboxes with minimal effort.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
#!/usr/bin/env bash
set -euo pipefail
# ===== CONFIG =====
CSV="${1:-./mail_accounts.csv}"
OLD_HOST="IP/domain"
OLD_PORT="993"
OLD_SSL="1"
NEW_HOST="IP/domain"
NEW_PORT="993"
NEW_SSL="1"
# imapsync docker image
IMAGE="gilleslamiral/imapsync"
# Extra imapsync flags
COMMON_FLAGS=(--automap --subscribeall --nosslcheck)
# Concurrency (1 = sequential, safest)
JOBS="${JOBS:-1}"
# Dry run: DRYRUN=1 ./bulk_imapsync.sh
DRYRUN="${DRYRUN:-0}"
# Logs
LOGDIR="${LOGDIR:-./imapsync-logs}"
mkdir -p "$LOGDIR"
need() { command -v "$1" >/dev/null 2>&1 || { echo "Missing dependency: $1"; exit 1; }; }
need docker
run_one() {
local email="$1"
local oldpass="$2"
local newpass="$3"
# sanitize filename
local safe="${email//@/_}"
safe="${safe//\//_}"
local logfile="${LOGDIR}/${safe}.log"
echo "==> [$email] starting (log: $logfile)"
# build docker+imapsync command
local cmd=(docker run --rm "$IMAGE" imapsync
--host1 "$OLD_HOST" --port1 "$OLD_PORT" --user1 "$email" --password1 "$oldpass"
--host2 "$NEW_HOST" --port2 "$NEW_PORT" --user2 "$email" --password2 "$newpass"
"${COMMON_FLAGS[@]}"
)
if [[ "$OLD_SSL" == "1" ]]; then cmd+=(--ssl1); fi
if [[ "$NEW_SSL" == "1" ]]; then cmd+=(--ssl2); fi
# For visibility + logging
{
echo "Command: ${cmd[*]//${oldpass}/*OLDPASS*}"
echo "Command: ${cmd[*]//${newpass}/*NEWPASS*}"
echo "----- output -----"
} >"$logfile"
if [[ "$DRYRUN" == "1" ]]; then
echo "DRYRUN=1 set, not executing for $email"
return 0
fi
# Run and append output to log
if "${cmd[@]}" >>"$logfile" 2>&1; then
echo "==> [$email] OK"
else
echo "==> [$email] FAILED (see $logfile)" >&2
return 1
fi
}
export -f run_one
export OLD_HOST OLD_PORT OLD_SSL NEW_HOST NEW_PORT NEW_SSL IMAGE DRYRUN LOGDIR
export COMMON_FLAGS
# ===== MAIN =====
if [[ ! -f "$CSV" ]]; then
echo "CSV not found: $CSV" >&2
exit 1
fi
echo "Using CSV: $CSV"
echo "Old: $OLD_HOST:$OLD_PORT (ssl=$OLD_SSL)"
echo "New: $NEW_HOST:$NEW_PORT (ssl=$NEW_SSL)"
echo "Logs: $LOGDIR"
echo "Jobs: $JOBS"
echo
# Read CSV, skip header if present, skip blanks/comments
# Format: email,old_pass,new_pass
mapfile -t lines < "$CSV"
# Build a temp list without header/comments/blanks
tasks=()
for line in "${lines[@]}"; do
line="${line%$'\r'}"
[[ -z "${line// }" ]] && continue
[[ "${line:0:1}" == "#" ]] && continue
[[ "$line" == "email,old_pass,new_pass" ]] && continue
tasks+=("$line")
done
if [[ "${#tasks[@]}" -eq 0 ]]; then
echo "No tasks found in CSV." >&2
exit 1
fi
# Run sequentially (default) or in parallel if JOBS>1
if [[ "$JOBS" -le 1 ]]; then
for line in "${tasks[@]}"; do
IFS=',' read -r email oldpass newpass <<<"$line"
# basic validation
if [[ -z "${email:-}" || -z "${oldpass:-}" || -z "${newpass:-}" ]]; then
echo "Skipping invalid line: $line" >&2
continue
fi
run_one "$email" "$oldpass" "$newpass"
done
else
# Parallel mode using xargs (still simple)
printf "%s\n" "${tasks[@]}" | xargs -P "$JOBS" -I{} bash -c '
IFS=, read -r email oldpass newpass <<<"{}"
[[ -z "${email:-}" || -z "${oldpass:-}" || -z "${newpass:-}" ]] && exit 0
run_one "$email" "$oldpass" "$newpass"
'
fi
echo
echo "All done. Check logs in: $LOGDIR"
A few notes on the example above:
- It uses Docker purely for convenience, so you do not need to install
imapsyncdirectly on the host. - It defaults to sequential execution, which is slower but safer.
- It supports dry runs and per-account logs, which makes troubleshooting much easier.
- It includes
--nosslcheckfor convenience, but that should be used with caution. In a proper production migration, valid certificates andTLSverification are preferable.
A practical migration order
The tool itself is only part of the process. The real difference between a painful migration and a smooth one is usually the order of operations.
A simple and effective migration flow looks like this:
- Prepare the destination server
- Create all mailboxes on the new server.
- Verify IMAP access on both source and destination.
- Make sure mailbox quotas, aliases, and any non-mail settings are handled separately if needed.
- Run an initial sync
- Migrate mailbox contents before cutover.
- This usually transfers the bulk of the data while the old service is still active.
- Lower DNS TTL in advance, if possible
- If you control the DNS zone ahead of time, lowering TTL before the migration window can help speed up the cutover.
- Update mail-related DNS records at cutover
- Update
MX - Update
SPF - Update
DKIM - Update
DMARC - And, if relevant to your setup, update
AutodiscoverorAutoconfigrecords as well
- Update
- Allow time for propagation
- This depends on
TTLand resolver caching, so it is better to think in terms of a cutover window rather than a fixed “wait 24 hours” rule.
- This depends on
- Run a final resync
- Once mail flow has shifted to the new provider, rerun the sync to catch any messages that arrived during the transition window.
- Validate the result
- Spot-check mailbox contents.
- Compare folder structure and message counts.
- Confirm that sending and receiving works correctly on the new service.
- Decommission the old setup
- Only remove the old service once you are satisfied everything has been migrated and users are stable on the new platform.
Why this approach works well
The best part of this workflow is that it is not tied to one specific provider. As long as both sides support compatible IMAP access, imapsync can usually do the heavy lifting, whether the migration involves hosted platforms or self-managed mail servers.
That does not mean every provider behaves identically. Some may require app passwords, IMAP enablement, special authentication settings, or additional handling around throttling and folder mapping. But the overall migration pattern remains the same.
From the user’s perspective, a migration done this way can be nearly transparent, especially if:
- mailbox contents were pre-synced,
- DNS was cut over cleanly,
- the final resync was performed,
- and client configuration is either unchanged or centrally managed.
Of course, if passwords change, server settings change, or mail clients are manually configured, some user-visible disruption is still possible. But compared to manual mailbox exports, ad hoc copies, or provider-specific guesswork, this method is simple, repeatable, and remarkably effective.
Final thoughts
Email migrations are rarely fun, but they do not have to be chaotic.
imapsync is one of those tools that quietly solves a very real operational problem. Combined with a small script and a sensible cutover process, it can turn a tedious mailbox migration into something far more controlled and far less stressful.
Simple, practical, and effective, exactly the kind of tool sysadmins tend to appreciate most.