A self-contained mass-migrations orchestrator for Gmail.
Run one binary, open the browser, add dozens of source mailboxes, authorize each destination Gmail once, and sync them in parallel with live per-account logs.
TL;DR: As a Google Admin, I needed a migration tool that was fast, observable, and reliable. The Google Import Tool was none of those: slow, opaque, and hard to debug when it failed.
┌──────────────┐ ┌─────────────────────┐ ┌───────────────────┐
│ origin IMAP │ ──▶ │ imap2gmail (local) │ ──▶ │ Gmail (per-user) │
│ (host:port) │ │ + imapsync workers │ │ via OAuth token │
└──────────────┘ └─────────────────────┘ └───────────────────┘
│
┌──────┴──────┐
│ browser UI │ jobs orchestration
└─────────────┘
- Bulk import - paste CSV (
source_user,password,gmail) to add many accounts at once. - Per-account Gmail OAuth - authorize each destination once; access tokens are refreshed automatically sync.
- Bounded parallelism - a worker pool (1–8 concurrent syncs) processes checked accounts.
- Live logs - combined imapsync stdout/stderr streamed per operation via SSE, with status indicators (running/stopped).
- Clean Stop - Stop / Ctrl-C kills the whole process group (imapsync + forked helpers), persists
stoppedstatus. - Dry Run -
--dryis on until you explicitly turn it off.
- imapsync on
PATH(the app preflights this on startup). Install e.g. withbrew install imapsync. - A Google OAuth client (web app type) - you need its Client ID and Secret, and you must add the redirect URI (see Configuration) to the authorized redirect URIs in Google Cloud Console.
# Build the self-contained binary (generates sqlc, builds frontend, embeds it)
task buildThis produces imap2gmail. The frontend is compiled via Vite and embedded into the Go binary via //go:embed.
./imap2gmailMake sure you're running imap2gmail in a secure environment. Treat the environment as you would treat .env.prod files.
The server binds 127.0.0.1:<bind_port> (default 8080) and opens a browser to it. All state lives under the current working directory.
Open the Settings panel in the UI (its collapse state is remembered across reloads):
| Setting | Default | Notes |
|---|---|---|
| OAuth Client ID / Secret | (empty) | Google OAuth Web app credentials |
| Origin Host / Port / SSL | "" / 993 / on |
Your source IMAP server |
| Bind Port | 8080 |
Loopback listen port |
| imapsync flags | see below | Global behavior flags (validated) |
| Max Concurrent | 1 |
Worker pool, clamped 1–8 |
| Dry Run | on | Adds --dry until you turn it off |
The redirect URL is derived, not stored: http://127.0.0.1:<bind_port>/. Add it to Authorized redirect URIs in your Google OAuth client. Changing bind_port requires re-registering the URI and restarting the app (the server only rebinds on restart).
- Click Auth on an account → the app opens Google's consent URL (requesting offline access + a refresh token).
- Google redirects to
http://127.0.0.1:<bind_port>/?code=…&state=<nonce>. - The app exchanges the code, stores the refresh token, and marks the destination authenticated.
- During sync, access tokens are minted from the refresh token and refreshed every 5 minutes. A manual Exchange Code fallback is available if the redirect flow can't complete.
- Configure the origin IMAP host/port/SSL and your Google OAuth credentials, then Save.
- Authorize each destination Gmail (Auth button per account).
- Check the accounts you want to migrate (Select All / Select None, or per-row).
- Sync All (or Sync per row). The log pane auto-switches to the first syncing account and shows live output.
- Watch the status badges: Running (pulsing), OK, Failed, Skipped, Stopped, Idle.
- Stop at any time - running accounts are killed and marked
stopped.
Dry Run is on by default. Turn it off in Settings to perform a real migration.
The runner is a single global orchestrator (one sync run at a time):
- Queue - checked accounts (skipping duplicates and unauthenticated destinations) enter a worker pool bounded by
max_concurrent. - Per account - mint a Gmail access token → write a
0600token file → build the imapsync argv → spawn imapsync in its own process group. - imapsync argv - the app owns connection/auth flags: source (
--host1/--port1/--ssl1/--user1/--password1), destination hardwired to Gmail (--host2 imap.gmail.com --port2 993 --ssl2 --user2 <gmail> --oauthaccesstoken2 <tokenfile> --gmail2 --nolog), plus your global behavior flags, plus--dryif Dry Run is on. - Stream - combined stdout/stderr is written to the operation log file and pushed to the browser over SSE.
- Status - each account ends as
ok,failed,skipped, orstopped.
The global imapsync flag string is tokenized with shlex (never a shell) and validated against a denylist of app-managed flags. You cannot override connection, credentials, identity, OAuth, --dry, or logging flags (--log/--nolog/--logfile/--logdir) - those are owned by the app.
- Stop button cancels the run and sends
SIGKILLto each child's whole process group (so forked perl/imapsync helpers die and pipes close). In-flight accounts are markedstopped; not-yet-started ones stayidle. - Ctrl-C / SIGTERM triggers graceful shutdown: stop the runner, wait up to 15s for finalization, then shut down the HTTP server (5s).
- Startup recovery clears any stale
runningstatus left by a crash.
- Backend: Go 1.26.
- Frontend: React 19, Mantine 9, TanStack React Query 5, TypeScript, Vite 8 - embedded into the binary at build time.
- Engine: imapsync invoked as a subprocess.
This project invokes imapsync as an external program. imapsync is not part of this
project’s license. See the imapsync project for its own license terms.
MIT