Add OnActivity callback to CopyDeps to prevent stall timeouts during large message transfers Implement touchReader and touchWriter wrappers to call OnActivity during FETCH and APPEND operations Add slow message logging to identify performance bottlenecks Add test case to verify activity reporting during message transfers Clean up orchestrator account reset code formatting
imap-copier
Single-binary Go server (embeds the React SPA) that copies IMAP mailboxes between a source and a destination account. Non-destructive (copy only, never deletes), deduplicated by Message-ID, resumable/idempotent re-runs.
Screenshots
Task list — create migration jobs and track their status:
Task detail — run control, per-account credentials, live event log and progress:
Quick start (plain HTTP on :80)
cp .env.example .env
# generate a real 32-byte key for ENC_KEY:
sed -i '' "s|ENC_KEY=|ENC_KEY=$(openssl rand -base64 32)|" .env
# edit .env: set POSTGRES_PASSWORD, AUTH_USER, AUTH_PASS, SESSION_SECRET
docker compose build
docker compose up -d
curl -fsS http://localhost/healthz
The app is served through Caddy on port 80 by default — no domain or TLS
required. Login with AUTH_USER/AUTH_PASS from .env.
Enabling HTTPS (Let's Encrypt)
Set a real, publicly resolvable domain and an ACME contact email in .env:
DOMAIN=copier.example.com
ACME_EMAIL=you@example.com
Then start with the TLS override instead of the plain compose file:
docker compose -f docker-compose.yml -f docker-compose.tls.yml up -d
# or: make up-tls
This swaps Caddy's config to Caddyfile.tls, which requests and renews a
Let's Encrypt certificate automatically for DOMAIN (ports 80/443 must be
reachable from the internet for the ACME HTTP-01 challenge). Switch back to
plain HTTP with docker compose -f docker-compose.yml up -d (or make up).
Environment variables (.env)
| Var | Required | Notes |
|---|---|---|
POSTGRES_PASSWORD |
yes | Postgres password, also used in DATABASE_URL |
AUTH_USER / AUTH_PASS |
yes | Single operator login (no user table) |
ENC_KEY |
yes | 32 bytes, base64: openssl rand -base64 32 |
SESSION_SECRET |
yes | Signs the session cookie |
WORKER_CONCURRENCY |
no (default 4) | Parallel accounts copied per run |
HTTP_PORT |
no (default 80) | Host port Caddy binds for HTTP |
DOMAIN / ACME_EMAIL |
only for HTTPS | See above |
Makefile targets
make build # docker compose build
make up # docker compose up -d (plain HTTP)
make up-tls # docker compose up -d with the Let's Encrypt override
make down # docker compose down
make logs # docker compose logs -f
make test # go test ./...
make e2e # scripts/e2e.sh (full-stack E2E against greenmail)
E2E test
scripts/e2e.sh builds and starts the full stack (postgres, app, caddy)
plus a throwaway greenmail IMAP server acting as both the source and
destination mailbox host, then drives the real REST API: login, create
endpoints/task/account, /test, /run, and a second /run to prove
idempotency (nothing is re-copied). It tears everything down afterwards.
bash scripts/e2e.sh
# or: make e2e
Known limitations
Deduplication key is UNIQUE(account_id, message_key) without folder. If
the same message appears in multiple source folders (e.g. Gmail INBOX +
[Gmail]/All Mail + labels-as-folders), it is copied only into whichever
destination folder is processed first; folder placement for such duplicated
messages is not guaranteed. This is intentional per the design spec.
Architecture
cmd/server— entrypoint: runs DB migrations, then serves HTTP.internal/httpapi— REST API + WebSocket + embedded SPA (webdist/).internal/orchestrator— test/run coordination, per-account concurrency.internal/imapx— IMAP connect/list/copy primitives.internal/store— Postgres access (endpoints, tasks, accounts, runs).web/— React/Vite SPA, built intointernal/httpapi/webdistat image build time (seeDockerfile).

