Skip to content

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 bare status: 0 with no reason arrives as source_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_limited and 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 — --noexpungeaftereach is 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”.

By far the most common, and almost always one of:

  • The master user is wrong. The login is user@example.com*master by 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 with 409 no_credential until 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 fails auth: 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 auth until there is a master user, or a password per mailbox (cPanel, Plesk).
  • It is Google Workspace and the delegation is incomplete. invalid_grant on every mailbox means the service account’s client ID was not authorised for https://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 auth part 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) for could 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.All on Graph, and opening a mailbox needs IMAP.AccessAsApp on Office 365 Exchange Online — admin-consented — and the app’s service principal registered in Exchange Online with FullAccess on 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:

Terminal window
openssl s_client -crlf -connect <host>:993
a LOGIN "user@example.com*master" "<password>"

Hostname, port, or a firewall. Check the obvious first:

Terminal window
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/null

The worker image has no nc or curl. For a port without TLS (143), node opens a bare socket:

Terminal window
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.

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.

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_SECOND on 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.

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.

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:

Terminal window
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.

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:

Terminal window
docker compose -f /opt/mailserver/compose/compose.yaml --env-file /opt/mailserver/.env logs worker | grep migration