When a migration fails
Every failed mailbox carries an error code. This maps each one to a cause and a fix.
Failures reading a directory — rather than copying mail — come back as source_auth, source_tls,
source_unreachable, source_rate_limited or source_error, and are covered per source in
Migrating from cPanel,
Migrating from Plesk,
Migrating from Google Workspace and
Migrating from Microsoft 365.
Two provider quirks are worth knowing, because both would otherwise send you to the wrong place:
- cPanel and Plesk report a rejected credential at HTTP 200. “The source has no mailboxes” and “your
credential is wrong” are the same wire response; the connectors read the reply’s own error rather than the
status. Plesk’s refusal arrives as
source_auth. cPanel’s does only when its error text says so (permission, access denied, not authorised, privilege) — a barestatus: 0with no reason arrives assource_error, and a wrong token is still the first thing to check. - Google says 403 for both “not allowed” and “not so fast.” The reason string separates them, so a
throttle arrives as
source_rate_limitedand never as a permissions problem.
Feature: Migration. The happy path: Migrating a domain and cutting over.
Two things are true of every failure here and worth knowing before you start:
- Nothing on the source is ever modified. No delete or expunge option is ever enabled —
--noexpungeaftereachis passed explicitly. A failed migration cannot have damaged the customer’s old mail. - Re-running is always safe. Messages already copied are skipped, so the fix for most of these is “correct the cause, run again” — never “start over cleanly”.
auth — the source rejected the login
Section titled “auth — the source rejected the login”By far the most common, and almost always one of:
- The master user is wrong. The login is
user@example.com*masterby convention. Some hosts use a different separator, and some have no master user at all — in which case each mailbox needs its own password, entered when mapping. - The password is wrong, or was rotated on the source since you entered it.
- The credential was destroyed — though that stops a run before it can fail on
auth. Finishing a cutover or cancelling a job wipes it deliberately; the job’s Source step then says so and offers to re-enter it, the start button is disabled, and the API refuses a run with409 no_credentialuntil you do. That form re-enters an Any IMAP server credential only: on a cPanel, Plesk, Google Workspace or Microsoft 365 job it saves a credential of the wrong kind, and every mailbox then failsauth: stored credential could not be opened. For those jobs, create the migration again instead. - The source requires an app password. An Any IMAP server job pointed at Gmail or Exchange Online is
refused an ordinary account password over IMAP. (The Google Workspace and Microsoft 365 job types never use a
password — they sign in with a token — and a plain IMAP job gets none of their folder rules; see
folder.) - It is a control panel and you only gave the panel credential. A cPanel token or a Plesk key discovers;
neither can open a mailbox, and neither can recover a password. Every mailbox fails on
authuntil there is a master user, or a password per mailbox (cPanel, Plesk). - It is Google Workspace and the delegation is incomplete.
invalid_granton every mailbox means the service account’s client ID was not authorised forhttps://mail.google.com/in the Admin console. If the token works but the login does not, IMAP is switched off for that user — the connection test says so in those words. Neither is a password problem. - A Workspace run failed on
authpart way through a large mailbox. It should not: the token is refreshed under the run every twenty minutes. If it does, check the job log on the Copy step (Warnings) forcould not refresh the source token— the service account may have been revoked mid-migration. - Microsoft 365 discovers fine and then fails every single mailbox. Almost always the second token.
Graph and Exchange Online are different resources: listing users needs
User.Read.Allon Graph, and opening a mailbox needsIMAP.AccessAsAppon Office 365 Exchange Online — admin-consented — and the app’s service principal registered in Exchange Online withFullAccesson that mailbox. A granted-but-unconsented permission, or a mailbox the service principal was not given, fails exactly like a wrong password (Migrating from Microsoft 365). - It is not a credential problem at all. The worker files every failure to build the source endpoint
as
auth(apps/worker/src/jobs/migration.ts) — including a Google Workspace or Microsoft 365 token mint that failed because the token endpoint was unreachable or rate-limiting us. Read the detail: a network error or an HTTP 429/5xx there means retry later, not re-enter anything. - The address does not exist on the source. A mistyped or deleted source mailbox fails its login and
lands here, not under
source_missing.
Verify the credential independently before blaming the tool:
openssl s_client -crlf -connect <host>:993a LOGIN "user@example.com*master" "<password>"connection — could not reach the source
Section titled “connection — could not reach the source”Hostname, port, or a firewall. Check the obvious first:
docker compose -f /opt/mailserver/compose/compose.yaml --env-file /opt/mailserver/.env exec worker getent hosts <source-host>docker compose -f /opt/mailserver/compose/compose.yaml --env-file /opt/mailserver/.env exec -T worker openssl s_client -connect <source-host>:993 -brief </dev/nullThe worker image has no nc or curl. For a port without TLS (143), node opens a bare socket:
docker compose -f /opt/mailserver/compose/compose.yaml --env-file /opt/mailserver/.env exec -T worker node -e "require('net').connect(143, '<source-host>').on('connect', () => { console.log('open'); process.exit(0) }).on('error', (e) => { console.error(e.message); process.exit(1) })"Run it from inside the worker container, not from your laptop — the worker is what has to reach the source, and a name that resolves for you may not resolve for it.
A source with the same name as this server. Moving from an old host to MailDock while keeping the
mail hostname — mail.example.com before and after — is a common cut-over. Up to and including
0.4.1, the worker resolved this server’s own mail hostname to its own postfix container, so a
source by that name failed with Could not reach the source server … IO::Socket::IP configuration failed: the job reached postfix, which does not answer 993. getent hosts <source-host> in the worker
shows it — a 172.x or 192.168.x address instead of the old host’s public one. The workaround is the
source’s IP address as the host (tick Accept an invalid TLS certificate, since the certificate names
the hostname, not the IP). 0.4.2 fixes it: the name resolves the way the internet resolves it.
It also covers a mailbox whose copy exceeded its ceiling: 12 hours per mailbox, fixed — there is no
setting for it. The detail reads imapsync exceeded 12h. Releases up to 0.3.0-rc.6 print
imapsync exceeded undefinedms for the same thing; it still means the 12 hours ran out. A single mailbox
taking that long usually means the source is throttling hard; see rate_limited.
tls — the certificate did not verify
Section titled “tls — the certificate did not verify”Expired, self-signed, or issued for a different name. Common on the sort of ageing host people migrate away from.
Fix it on the source if you can. If you cannot, re-create the migration with Accept an invalid TLS certificate ticked — but understand what that costs: you keep the encryption and lose the guarantee that you are talking to the right server. Only do it for a host you recognise, on a network you trust.
quota — the destination mailbox is full
Section titled “quota — the destination mailbox is full”The destination, not the source. A migration writes the source mailbox’s full size, and the local quota was too small.
A mailbox the migration pre-created has no quota while the migration runs, and takes it only when the migration
ends (migration.md → Quotas), so it does not hit this. Expect
it on a mailbox that already existed and was mapped to the source, one pre-created with map off, or one whose
quota an administrator set by hand during the migration.
Raise the quota (Domains → the domain → the mailbox) and run again. Nothing already copied is copied twice, so the re-run only moves what did not fit.
Check the whole set before re-running — if one mailbox hit its quota, others probably will.
rate_limited — the source is throttling us
Section titled “rate_limited — the source is throttling us”Big providers do this, and being aggressive makes it worse, not faster.
- Set
MIGRATION_MAX_BYTES_PER_SECONDon the worker to something the source tolerates. - Lower
MIGRATION_CONCURRENCY— several parallel mailboxes from one account is often what trips it. - Wait and run again. Some providers impose a daily ceiling that nothing but time will clear.
Both settings are read when the worker starts: change them in .env, then docker compose -f /opt/mailserver/compose/compose.yaml --env-file /opt/mailserver/.env up -d worker
before running again.
folder — a folder could not be selected or created
Section titled “folder — a folder could not be selected or created”Usually a name the destination will not accept: an unusual separator, or a character Dovecot rejects. The job log has the folder name.
Google Workspace jobs have folder rules applied automatically ([Gmail]/Sent Mail → Sent, and All Mail,
Important and Starred excluded because they repeat messages held in other folders). The rules follow the
job’s source type alone, never the server it connects to: Gmail copied through an Any IMAP server job
gets none of them, and that is the likely cause. Re-create it as a Google Workspace job.
source_missing — the mailbox does not exist on the source
Section titled “source_missing — the mailbox does not exist on the source”A running job produces this only when imapsync’s output matches its “host1 not reachable” pattern
(classifyError, packages/migration/src/imapsync.ts) — and most unreachable-host messages match the
connection pattern first. A source mailbox that does not exist usually fails its IMAP login instead, so
a typo in the pasted address or a mailbox deleted on the source after you built the list shows up as
auth. Either way: correct the address in the mapping table, or remove the row.
target_missing — no local mailbox is mapped
Section titled “target_missing — no local mailbox is mapped”The source address has no destination, or the mailbox it pointed at has since been deleted. Pick a destination in the mapping table. If the local mailbox does not exist yet, create it first — a migration copies into mailboxes, it does not create them.
cancelled — stopped by an operator
Section titled “cancelled — stopped by an operator”Not a failure. A mailbox shows cancelled when a run was stopped under it — by Pause as well as by
Cancel — and goes back to waiting. Running again re-runs it, skipping what was already copied. After a
real cancel, though, the stored credentials were destroyed too and must be re-entered before it can run;
use pause instead when you intend to resume.
unknown — something unrecognised
Section titled “unknown — something unrecognised”From a running job, the log has imapsync’s own words, which is why it is kept verbatim (with credentials stripped). Filter it to Errors on the job’s Copy step.
From the connection test, the detail is the source IMAP server’s own reply, not imapsync’s — the
probe never runs imapsync. Expect the server’s response code in brackets where it sent one, as in
auth: [AUTHENTICATIONFAILED] Authentication failed. A bare unknown from a probe therefore means the
source said something none of the patterns above recognise, and the words after the colon are the
source’s, quoted exactly. They are worth reading literally: they come from the other side.
If you need more, run the same copy by hand from inside the worker container:
docker compose -f /opt/mailserver/compose/compose.yaml --env-file /opt/mailserver/.env exec worker imapsync \ --host1 <source> --user1 'user@example.com*master' --password1 '<password>' --ssl1 \ --host2 dovecot --port2 143 --user2 'user@example.test*mailserver-api' --password2 '<master>' \ --nolog --dry--dry writes nothing, and --nolog keeps imapsync from writing a log file into the worker’s working directory,
which it cannot write: the worker runs as uid 2525, and without it every run ends with a
Permission denied error that is not the problem you are looking for. This is the core of what the worker runs, not all of it: the worker also passes
--nofoldersizes --nofoldersizesatend --noreleasecheck --nolog --syncflags --automap --noexpungeaftereach,
--tmpdir for its cache, the source type’s --exclude and --regextrans2 folder rules,
--maxbytespersecond when one is set, and --maxage on a delta. Add whichever of those the problem might
depend on.
Things that look like failures and are not
Section titled “Things that look like failures and are not”“Copied 0, skipped 4,312.” The correct result of a delta run when nothing new arrived. It is what a healthy final cutover step looks like.
Counts differ slightly from the source’s. Two kinds of message are counted by the source and not copied.
imapsync recognises a message by its Message-Id and Received headers, so a second copy of the same message
in one folder is skipped as a duplicate, and a message with neither header is skipped as unidentifiable. The
run counts both in its skipped figure, not as copied — which is why the job’s Copy step says skipped — already here or not copyable rather than already here. Until October 2026 it said the latter, and a customer reconciling counts was told a message had arrived when it had not. Messages flagged deleted but not yet expunged are not a cause: they
are copied with their \Deleted flag, and they disappear here when a mail client next expunges the folder,
as they would have on the source.
A filter was “not imported”. Deliberate: a cPanel filter, Gmail filter or Microsoft 365 inbox rule that this server cannot match exactly is listed rather than approximated, because a filter that quietly misfiles mail for a month is worse than one you were told to recreate. The reason names what got in the way. A Gmail filter or Microsoft 365 rule with more than 20 conditions or 10 actions — the limits of one rule here — is refused, and the note names the count. A Microsoft 365 rule that uses a condition the importer does not recognise is refused, and the note names the key. A cPanel filter over those limits is refused the same way.
A Workspace mailbox copied about twice as many messages as Gmail showed. [Gmail]/All Mail is every
message a second time and is excluded by default. If you see this, the exclusion did not apply, and the usual
reason is the job’s source type: it was created as Any IMAP server, which gets no folder rules (see folder
above). The job log does not show imapsync’s arguments, because it keeps only imapsync’s warning and error lines. To see
them, run the copy by hand as in unknown above, with --exclude '^\[Gmail\]/All Mail$' added, and compare.
A Microsoft 365 rule was “not imported” because of exceptions. Deliberate, like the refusals above. Outlook ands its conditions and then ors its exceptions over the top; our rules carry one combinator, and folding the two together would change what the rule matches. Recreate it by hand.
Mail is not being forwarded after a Microsoft 365 cutover. Check the Exchange admin centre:
mailbox-level forwarding (ForwardingSmtpAddress) is not exposed by Graph and was never read. Only
forwarding set up as an inbox rule is imported, and discovery warns about this on every run.
A Plesk migration imported no filters at all. Not a failure: Plesk exposes none through its API, so there was nothing to read. Discovery says so in its warnings. If the old server’s users had Sieve scripts through ManageSieve, the panel cannot see those either — copy them off the old server before the cutover.
A Plesk mailbox shows no size. Plesk’s API does not report one. The copy is unaffected; only the estimate
is. imapsync reports the real numbers per mailbox once the run starts.
A mailbox shows pending for a long time. Only MIGRATION_CONCURRENCY mailboxes run at once, across
every job on the server. Running jobs share those slots: each slot that frees goes to the job with the fewest
copies in flight, so a second job starts at the next free slot, and a mailbox waits at most for the copies ahead
of it to end (one copy can take up to 12 hours; a copy is never interrupted to make room). Jobs were once worked
one after another. Raise it (in .env, then docker compose -f /opt/mailserver/compose/compose.yaml --env-file /opt/mailserver/.env up -d worker) if the source can take the
load.
Progress sits still on a big mailbox. Counters flush every few seconds, but imapsync spends the start of each folder building its index and reports nothing while it does. The job log is the better signal.
The job did not start immediately. The worker sweeps every MIGRATION_SWEEP_INTERVAL_MINUTES (default
1). If a sweep is already copying another job, the new job starts at the next free slot instead, within about 15 s
when a slot is idle. Set the interval in .env and recreate the worker
(docker compose … up -d worker) to change it.
When migrations do not run at all
Section titled “When migrations do not run at all”If creating a migration, re-entering its credential, adding mailboxes or reading a saved source answers
503 migration_unavailable, MIGRATION_SECRET_KEY is not set — reading a job does not check the key. Run mailctl upgrade
to back-fill it. The API refuses rather than storing a third party’s password in the clear. An .env written by mailctl install
or mailctl upgrade always has one, so this means an .env built by hand, or one from before migrations
existed that has not been upgraded. mailctl upgrade back-fills an absent line only: if MIGRATION_SECRET_KEY=
is present but empty, delete it first.
If Start migration answers 402 license_degraded, the licence has reached the degraded stage or beyond: a
migration cannot be started or resumed until it is renewed (licensing). A run
already copying finishes, and pausing, cancelling and the cutover stay open.
If jobs sit in running with nothing happening, check the worker is up, and that it has
DOVECOT_MASTER_PASSWORD as well as MIGRATION_SECRET_KEY — without either it never starts the migration
queue, and says migration queue disabled once at boot. Then check it shares the same
MIGRATION_SECRET_KEY as the api — the api seals credentials, the worker opens them, and a mismatch shows up
as auth on every mailbox:
docker compose -f /opt/mailserver/compose/compose.yaml --env-file /opt/mailserver/.env logs worker | grep migration