Merge feat/imap-copier-mvp: imap-copier MVP

IMAP-to-IMAP mail migration tool: Go backend (streaming, non-destructive,
idempotent copy), React realtime UI, Postgres, Docker+Caddy. Full-stack E2E
proved idempotency; final review Critical (double-run race) fixed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMHQTtnQtQqL8muAXHr9kd
This commit is contained in:
2026-07-01 19:39:28 +07:00
82 changed files with 6345 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
.git
.gitignore
.superpowers
.claude
swarm-report
*.md
!README.md
web/node_modules
web/dist
**/*.log
.env
.env.*
!.env.example
.DS_Store
+11
View File
@@ -0,0 +1,11 @@
POSTGRES_PASSWORD=change-me
AUTH_USER=admin
AUTH_PASS=change-me
# 32 bytes base64-encoded: openssl rand -base64 32
ENC_KEY=
SESSION_SECRET=change-me-long-random
WORKER_CONCURRENCY=4
HTTP_PORT=80
# For HTTPS + Let's Encrypt (docker-compose.tls.yml override): set a real domain and email
DOMAIN=
ACME_EMAIL=
+8
View File
@@ -0,0 +1,8 @@
.env
.env.*
!.env.example
/internal/httpapi/webdist/*
!/internal/httpapi/webdist/index.html
.DS_Store
+5
View File
@@ -0,0 +1,5 @@
# Default profile: plain HTTP on :80, no TLS termination.
# Used by: docker compose up -d
:80 {
reverse_proxy app:8080
}
+11
View File
@@ -0,0 +1,11 @@
# HTTPS profile: automatic Let's Encrypt certificate for $DOMAIN.
# Used by: docker compose -f docker-compose.yml -f docker-compose.tls.yml up -d
# Requires DOMAIN (and ACME_EMAIL) set in .env, and the server's :80/:443
# reachable from the internet for the ACME HTTP-01 challenge.
{
email {$ACME_EMAIL}
}
{$DOMAIN} {
reverse_proxy app:8080
}
+29
View File
@@ -0,0 +1,29 @@
# syntax=docker/dockerfile:1
# 1) build web (React/Vite SPA)
FROM node:22-alpine AS web
WORKDIR /web
COPY web/package.json web/package-lock.json ./
RUN npm ci
COPY web/ ./
RUN npm run build
# 2) build go (embed web build via internal/httpapi/webdist)
FROM golang:1.24-alpine AS go
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# overwrite the committed webdist stub with the real SPA build
RUN rm -rf ./internal/httpapi/webdist
COPY --from=web /web/dist ./internal/httpapi/webdist
RUN CGO_ENABLED=0 go build -trimpath -o /out/server ./cmd/server
# 3) runtime
FROM alpine:3.20
RUN apk add --no-cache ca-certificates
WORKDIR /app
COPY --from=go /out/server /app/server
COPY migrations /app/migrations
EXPOSE 8080
ENTRYPOINT ["/app/server"]
+22
View File
@@ -0,0 +1,22 @@
.PHONY: build up up-tls down logs test e2e
build:
docker compose build
up:
docker compose up -d
up-tls:
docker compose -f docker-compose.yml -f docker-compose.tls.yml up -d
down:
docker compose down
logs:
docker compose logs -f
test:
go test ./...
e2e:
bash scripts/e2e.sh
+97
View File
@@ -0,0 +1,97 @@
# 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.
## Quick start (plain HTTP on :80)
```bash
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:
```bash
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
```bash
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
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 into `internal/httpapi/webdist` at image
build time (see `Dockerfile`).
+56
View File
@@ -0,0 +1,56 @@
package main
import (
"context"
"log/slog"
"net/http"
"os"
"github.com/golang-migrate/migrate/v4"
_ "github.com/golang-migrate/migrate/v4/database/postgres"
_ "github.com/golang-migrate/migrate/v4/source/file"
"github.com/vasyansk/imap-copier/internal/config"
"github.com/vasyansk/imap-copier/internal/httpapi"
"github.com/vasyansk/imap-copier/internal/orchestrator"
"github.com/vasyansk/imap-copier/internal/store"
"github.com/vasyansk/imap-copier/internal/wshub"
)
func main() {
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
cfg, err := config.Load()
if err != nil {
slog.Error("config", "err", err)
os.Exit(1)
}
if err := runMigrations(cfg.DatabaseURL); err != nil {
slog.Error("migrate", "err", err)
os.Exit(1)
}
st, err := store.New(context.Background(), cfg.DatabaseURL)
if err != nil {
slog.Error("store", "err", err)
os.Exit(1)
}
hub := wshub.New()
orch := orchestrator.New(st, hub, cfg.EncKey, cfg.WorkerConcurrency)
srv := httpapi.NewServer(cfg, st, orch, hub)
slog.Info("listening", "addr", cfg.HTTPAddr)
if err := http.ListenAndServe(cfg.HTTPAddr, srv.Router()); err != nil {
slog.Error("serve", "err", err)
os.Exit(1)
}
}
func runMigrations(dsn string) error {
m, err := migrate.New("file://migrations", dsn)
if err != nil {
return err
}
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
return err
}
return nil
}
+9
View File
@@ -0,0 +1,9 @@
# Override for the HTTPS/Let's Encrypt path.
# Usage: docker compose -f docker-compose.yml -f docker-compose.tls.yml up -d
# Requires DOMAIN and ACME_EMAIL to be set in .env.
services:
caddy:
volumes:
- ./Caddyfile.tls:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
+47
View File
@@ -0,0 +1,47 @@
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: imap
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: imapcopier
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U imap"]
interval: 5s
timeout: 3s
retries: 5
app:
build: .
environment:
DATABASE_URL: postgres://imap:${POSTGRES_PASSWORD}@postgres:5432/imapcopier?sslmode=disable
AUTH_USER: ${AUTH_USER}
AUTH_PASS: ${AUTH_PASS}
ENC_KEY: ${ENC_KEY}
SESSION_SECRET: ${SESSION_SECRET}
WORKER_CONCURRENCY: ${WORKER_CONCURRENCY:-4}
depends_on:
postgres:
condition: service_healthy
caddy:
image: caddy:2-alpine
ports:
- "${HTTP_PORT:-80}:80"
- "443:443"
environment:
DOMAIN: ${DOMAIN:-}
ACME_EMAIL: ${ACME_EMAIL:-}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
- app
volumes:
pgdata:
caddy_data:
caddy_config:
+22
View File
@@ -0,0 +1,22 @@
module github.com/vasyansk/imap-copier
go 1.24.0
require (
github.com/coder/websocket v1.8.15
github.com/emersion/go-imap/v2 v2.0.0-beta.8
github.com/golang-migrate/migrate/v4 v4.19.1
github.com/jackc/pgx/v5 v5.7.0
)
require (
github.com/emersion/go-message v0.18.2 // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/lib/pq v1.10.9 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/text v0.31.0 // indirect
)
+122
View File
@@ -0,0 +1,122 @@
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug=
github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48=
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.0 h1:FG6VLIdzvAPhnYqP14sQ2xhFLkiUQHCs6ySqO91kF4g=
github.com/jackc/pgx/v5 v5.7.0/go.mod h1:awP1KNnjylvpxHuHP63gzjhnGkI1iw+PMoIwvoleN/8=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+60
View File
@@ -0,0 +1,60 @@
package config
import (
"encoding/base64"
"fmt"
"os"
"strconv"
)
type Config struct {
HTTPAddr string
DatabaseURL string
AuthUser string
AuthPass string
EncKey []byte
SessionSecret []byte
WorkerConcurrency int
}
func Load() (Config, error) {
c := Config{
HTTPAddr: getenv("HTTP_ADDR", ":8080"),
DatabaseURL: os.Getenv("DATABASE_URL"),
AuthUser: os.Getenv("AUTH_USER"),
AuthPass: os.Getenv("AUTH_PASS"),
SessionSecret: []byte(os.Getenv("SESSION_SECRET")),
WorkerConcurrency: 4,
}
if v := os.Getenv("WORKER_CONCURRENCY"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 1 {
return Config{}, fmt.Errorf("WORKER_CONCURRENCY invalid: %q", v)
}
c.WorkerConcurrency = n
}
for k, v := range map[string]string{
"DATABASE_URL": c.DatabaseURL, "AUTH_USER": c.AuthUser,
"AUTH_PASS": c.AuthPass, "SESSION_SECRET": string(c.SessionSecret),
} {
if v == "" {
return Config{}, fmt.Errorf("%s is required", k)
}
}
key, err := base64.StdEncoding.DecodeString(os.Getenv("ENC_KEY"))
if err != nil {
return Config{}, fmt.Errorf("ENC_KEY must be base64: %w", err)
}
if len(key) != 32 {
return Config{}, fmt.Errorf("ENC_KEY must decode to 32 bytes, got %d", len(key))
}
c.EncKey = key
return c, nil
}
func getenv(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
+37
View File
@@ -0,0 +1,37 @@
package config
import (
"encoding/base64"
"testing"
)
func TestLoadRequiresEncKey32Bytes(t *testing.T) {
t.Setenv("DATABASE_URL", "postgres://x")
t.Setenv("AUTH_USER", "admin")
t.Setenv("AUTH_PASS", "pass")
t.Setenv("SESSION_SECRET", "secret")
t.Setenv("ENC_KEY", base64.StdEncoding.EncodeToString(make([]byte, 16))) // wrong size
if _, err := Load(); err == nil {
t.Fatal("expected error for 16-byte ENC_KEY, got nil")
}
}
func TestLoadDefaults(t *testing.T) {
t.Setenv("DATABASE_URL", "postgres://x")
t.Setenv("AUTH_USER", "admin")
t.Setenv("AUTH_PASS", "pass")
t.Setenv("SESSION_SECRET", "secret")
t.Setenv("ENC_KEY", base64.StdEncoding.EncodeToString(make([]byte, 32)))
cfg, err := Load()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg.HTTPAddr != ":8080" {
t.Errorf("HTTPAddr = %q, want :8080", cfg.HTTPAddr)
}
if cfg.WorkerConcurrency != 4 {
t.Errorf("WorkerConcurrency = %d, want 4", cfg.WorkerConcurrency)
}
}
+50
View File
@@ -0,0 +1,50 @@
package crypto
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"io"
)
func Encrypt(key, plaintext []byte) (string, error) {
gcm, err := newGCM(key)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
ct := gcm.Seal(nonce, nonce, plaintext, nil)
return base64.StdEncoding.EncodeToString(ct), nil
}
func Decrypt(key []byte, enc string) ([]byte, error) {
gcm, err := newGCM(key)
if err != nil {
return nil, err
}
raw, err := base64.StdEncoding.DecodeString(enc)
if err != nil {
return nil, err
}
ns := gcm.NonceSize()
if len(raw) < ns {
return nil, errors.New("ciphertext too short")
}
return gcm.Open(nil, raw[:ns], raw[ns:], nil)
}
func newGCM(key []byte) (cipher.AEAD, error) {
if len(key) != 32 {
return nil, errors.New("key must be 32 bytes (AES-256)")
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
return cipher.NewGCM(block)
}
+39
View File
@@ -0,0 +1,39 @@
package crypto
import (
"bytes"
"testing"
)
func TestEncryptDecryptRoundTrip(t *testing.T) {
key := make([]byte, 32)
enc, err := Encrypt(key, []byte("hunter2"))
if err != nil {
t.Fatalf("encrypt: %v", err)
}
if enc == "hunter2" {
t.Fatal("ciphertext must not equal plaintext")
}
got, err := Decrypt(key, enc)
if err != nil {
t.Fatalf("decrypt: %v", err)
}
if !bytes.Equal(got, []byte("hunter2")) {
t.Fatalf("got %q, want hunter2", got)
}
}
func TestEncryptNonDeterministic(t *testing.T) {
key := make([]byte, 32)
a, _ := Encrypt(key, []byte("x"))
b, _ := Encrypt(key, []byte("x"))
if a == b {
t.Fatal("two encryptions must differ (random nonce)")
}
}
func TestEncryptRejectsWrongKeySize(t *testing.T) {
if _, err := Encrypt(make([]byte, 16), []byte("x")); err == nil {
t.Fatal("16-byte key must be rejected (AES-256 requires 32)")
}
}
+44
View File
@@ -0,0 +1,44 @@
package crypto
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"fmt"
"strconv"
"strings"
"time"
)
// token = base64(user) "." expiryUnix "." base64(hmac)
func SignSession(secret []byte, user string, expiry time.Time) string {
payload := base64.RawURLEncoding.EncodeToString([]byte(user)) + "." +
strconv.FormatInt(expiry.Unix(), 10)
return payload + "." + sign(secret, payload)
}
func VerifySession(secret []byte, token string, now time.Time) (string, bool) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return "", false
}
payload := parts[0] + "." + parts[1]
if !hmac.Equal([]byte(parts[2]), []byte(sign(secret, payload))) {
return "", false
}
exp, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil || now.Unix() > exp {
return "", false
}
user, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return "", false
}
return string(user), true
}
func sign(secret []byte, payload string) string {
m := hmac.New(sha256.New, secret)
fmt.Fprint(m, payload)
return base64.RawURLEncoding.EncodeToString(m.Sum(nil))
}
+34
View File
@@ -0,0 +1,34 @@
package crypto
import (
"testing"
"time"
)
func TestSessionRoundTrip(t *testing.T) {
secret := []byte("s3cr3t")
now := time.Unix(1_700_000_000, 0)
tok := SignSession(secret, "admin", now.Add(time.Hour))
user, ok := VerifySession(secret, tok, now)
if !ok || user != "admin" {
t.Fatalf("verify = %q,%v want admin,true", user, ok)
}
}
func TestSessionRejectsExpired(t *testing.T) {
secret := []byte("s3cr3t")
now := time.Unix(1_700_000_000, 0)
tok := SignSession(secret, "admin", now.Add(-time.Second))
if _, ok := VerifySession(secret, tok, now); ok {
t.Fatal("expired token must be rejected")
}
}
func TestSessionRejectsTampered(t *testing.T) {
secret := []byte("s3cr3t")
now := time.Unix(1_700_000_000, 0)
tok := SignSession(secret, "admin", now.Add(time.Hour))
if _, ok := VerifySession([]byte("other"), tok, now); ok {
t.Fatal("wrong secret must be rejected")
}
}
+54
View File
@@ -0,0 +1,54 @@
package csvimport
import (
"encoding/csv"
"fmt"
"io"
"strings"
)
type Row struct {
SrcLogin string
SrcPass string
DstLogin string
DstPass string
}
func Parse(r io.Reader) ([]Row, error) {
cr := csv.NewReader(r)
cr.FieldsPerRecord = -1 // проверяем сами
var rows []Row
seen := map[string]bool{}
for {
rec, err := cr.Read()
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
line, _ := cr.FieldPos(0)
if len(rec) == 1 && strings.TrimSpace(rec[0]) == "" {
continue // encoding/csv уже пропускает голые пустые строки; это ветка ловит строки из одних пробелов
}
if len(rec) != 4 {
return nil, fmt.Errorf("line %d: expected 4 columns, got %d", line, len(rec))
}
for i := range rec {
rec[i] = strings.TrimSpace(rec[i])
if rec[i] == "" {
return nil, fmt.Errorf("line %d: column %d is empty", line, i+1)
}
}
if seen[rec[0]] {
return nil, fmt.Errorf("line %d: duplicate src_login %q", line, rec[0])
}
seen[rec[0]] = true
rows = append(rows, Row{SrcLogin: rec[0], SrcPass: rec[1], DstLogin: rec[2], DstPass: rec[3]})
}
if len(rows) == 0 {
return nil, fmt.Errorf("no rows parsed")
}
return rows, nil
}
+51
View File
@@ -0,0 +1,51 @@
package csvimport
import (
"strings"
"testing"
)
func TestParseOK(t *testing.T) {
rows, err := Parse(strings.NewReader("a@x,p1,a@y,p2\nb@x,p3,b@y,p4\n"))
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(rows) != 2 || rows[0].SrcLogin != "a@x" || rows[1].DstPass != "p4" {
t.Fatalf("bad rows: %+v", rows)
}
}
func TestParseRejectsBadColumns(t *testing.T) {
if _, err := Parse(strings.NewReader("a,b,c\n")); err == nil {
t.Fatal("3 columns must error")
}
}
func TestParseRejectsDuplicateSrc(t *testing.T) {
if _, err := Parse(strings.NewReader("a@x,p,a@y,p\na@x,q,c@y,q\n")); err == nil {
t.Fatal("duplicate src_login must error")
}
}
func TestParseRejectsEmptyField(t *testing.T) {
if _, err := Parse(strings.NewReader("a@x,,a@y,p\n")); err == nil {
t.Fatal("empty password must error")
}
}
func TestParseBlankLineKeepsCorrectLineNumber(t *testing.T) {
// blank physical line 2, malformed row on physical line 3
_, err := Parse(strings.NewReader("a@x,p1,a@y,p2\n\nbad,row,here\n"))
if err == nil {
t.Fatal("expected error for 3-column row")
}
if !strings.Contains(err.Error(), "line 3") {
t.Fatalf("error must reference physical line 3, got: %v", err)
}
}
func TestParseZeroRowsErrors(t *testing.T) {
if _, err := Parse(strings.NewReader("\n\n \n")); err == nil {
t.Fatal("expected error when no rows parsed")
}
}
+71
View File
@@ -0,0 +1,71 @@
package httpapi
import (
"encoding/json"
"net/http"
"strconv"
"github.com/vasyansk/imap-copier/internal/crypto"
"github.com/vasyansk/imap-copier/internal/store"
)
type AccountView struct {
ID int64 `json:"id"`
SrcLogin string `json:"src_login"`
DstLogin string `json:"dst_login"`
TestSrcStatus string `json:"test_src_status"`
TestDstStatus string `json:"test_dst_status"`
Status string `json:"status"`
Copied int64 `json:"copied"`
Skipped int64 `json:"skipped"`
Errors int64 `json:"errors"`
}
func accountDTO(a store.Account) AccountView {
return AccountView{
ID: a.ID, SrcLogin: a.SrcLogin, DstLogin: a.DstLogin,
TestSrcStatus: a.TestSrcStatus, TestDstStatus: a.TestDstStatus,
Status: a.Status, Copied: a.Copied, Skipped: a.Skipped, Errors: a.Errors,
}
}
func pathID(r *http.Request, name string) (int64, error) {
return strconv.ParseInt(r.PathValue(name), 10, 64)
}
func (s *Server) handleCreateAccount(w http.ResponseWriter, r *http.Request) {
taskID, err := pathID(r, "id")
if err != nil {
http.Error(w, "bad id", http.StatusBadRequest)
return
}
var body struct {
SrcLogin string `json:"src_login"`
SrcPass string `json:"src_pass"`
DstLogin string `json:"dst_login"`
DstPass string `json:"dst_pass"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
srcEnc, err := crypto.Encrypt(s.cfg.EncKey, []byte(body.SrcPass))
if err != nil {
http.Error(w, "encrypt", http.StatusInternalServerError)
return
}
dstEnc, err := crypto.Encrypt(s.cfg.EncKey, []byte(body.DstPass))
if err != nil {
http.Error(w, "encrypt", http.StatusInternalServerError)
return
}
id, err := s.store.CreateAccount(r.Context(), store.Account{
TaskID: taskID, SrcLogin: body.SrcLogin, SrcPassEnc: srcEnc,
DstLogin: body.DstLogin, DstPassEnc: dstEnc,
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusCreated, map[string]int64{"id": id})
}
+71
View File
@@ -0,0 +1,71 @@
package httpapi
import (
"crypto/subtle"
"encoding/json"
"net/http"
"time"
"github.com/vasyansk/imap-copier/internal/config"
"github.com/vasyansk/imap-copier/internal/crypto"
"github.com/vasyansk/imap-copier/internal/orchestrator"
"github.com/vasyansk/imap-copier/internal/store"
"github.com/vasyansk/imap-copier/internal/wshub"
)
const cookieName = "session"
type Server struct {
cfg config.Config
store *store.Store
orch *orchestrator.Orchestrator
hub *wshub.Hub
}
func NewServer(cfg config.Config, s *store.Store, orch *orchestrator.Orchestrator, hub *wshub.Hub) *Server {
return &Server{cfg: cfg, store: s, orch: orch, hub: hub}
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
var body struct {
User string `json:"user"`
Pass string `json:"pass"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
uOK := subtle.ConstantTimeCompare([]byte(body.User), []byte(s.cfg.AuthUser)) == 1
pOK := subtle.ConstantTimeCompare([]byte(body.Pass), []byte(s.cfg.AuthPass)) == 1
if !uOK || !pOK {
http.Error(w, "invalid credentials", http.StatusUnauthorized)
return
}
tok := crypto.SignSession(s.cfg.SessionSecret, body.User, time.Now().Add(24*time.Hour))
http.SetCookie(w, &http.Cookie{
Name: cookieName, Value: tok, Path: "/",
HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: 86400,
})
w.WriteHeader(http.StatusOK)
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{Name: cookieName, Value: "", Path: "/", MaxAge: -1})
w.WriteHeader(http.StatusOK)
}
func (s *Server) requireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie(cookieName)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
user, ok := crypto.VerifySession(s.cfg.SessionSecret, c.Value, time.Now())
if !ok || user != s.cfg.AuthUser {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
+95
View File
@@ -0,0 +1,95 @@
package httpapi
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/vasyansk/imap-copier/internal/config"
"github.com/vasyansk/imap-copier/internal/crypto"
)
func testServer() *Server {
return &Server{cfg: config.Config{
AuthUser: "admin", AuthPass: "pw", SessionSecret: []byte("sekret"),
}}
}
func TestLoginSetsCookie(t *testing.T) {
s := testServer()
req := httptest.NewRequest("POST", "/api/login", strings.NewReader(`{"user":"admin","pass":"pw"}`))
rw := httptest.NewRecorder()
s.handleLogin(rw, req)
if rw.Code != http.StatusOK {
t.Fatalf("code=%d", rw.Code)
}
if len(rw.Result().Cookies()) == 0 {
t.Fatal("no session cookie set")
}
}
func TestRequireAuthBlocksNoCookie(t *testing.T) {
s := testServer()
h := s.requireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }))
rw := httptest.NewRecorder()
h.ServeHTTP(rw, httptest.NewRequest("GET", "/api/tasks", nil))
if rw.Code != http.StatusUnauthorized {
t.Fatalf("want 401, got %d", rw.Code)
}
}
func TestRequireAuthAllowsValidCookie(t *testing.T) {
s := testServer()
// логинимся, забираем cookie, повторяем запрос
lr := httptest.NewRequest("POST", "/api/login", strings.NewReader(`{"user":"admin","pass":"pw"}`))
lrw := httptest.NewRecorder()
s.handleLogin(lrw, lr)
cookie := lrw.Result().Cookies()[0]
h := s.requireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }))
req := httptest.NewRequest("GET", "/api/tasks", nil)
req.AddCookie(cookie)
rw := httptest.NewRecorder()
h.ServeHTTP(rw, req)
if rw.Code != 200 {
t.Fatalf("want 200, got %d", rw.Code)
}
}
func TestLoginRejectsBadCredentials(t *testing.T) {
s := testServer()
req := httptest.NewRequest("POST", "/api/login", strings.NewReader(`{"user":"admin","pass":"wrong"}`))
rw := httptest.NewRecorder()
s.handleLogin(rw, req)
if rw.Code != http.StatusUnauthorized {
t.Fatalf("want 401, got %d", rw.Code)
}
}
func TestRequireAuthRejectsTamperedCookie(t *testing.T) {
s := testServer()
h := s.requireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }))
req := httptest.NewRequest("GET", "/api/tasks", nil)
req.AddCookie(&http.Cookie{Name: cookieName, Value: "not.a.validtoken"})
rw := httptest.NewRecorder()
h.ServeHTTP(rw, req)
if rw.Code != http.StatusUnauthorized {
t.Fatalf("want 401, got %d", rw.Code)
}
}
func TestRequireAuthRejectsTokenForDifferentUser(t *testing.T) {
s := testServer()
// token signed for a user that is NOT s.cfg.AuthUser ("admin")
tok := crypto.SignSession(s.cfg.SessionSecret, "olduser", time.Now().Add(time.Hour))
h := s.requireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }))
req := httptest.NewRequest("GET", "/api/tasks", nil)
req.AddCookie(&http.Cookie{Name: cookieName, Value: tok})
rw := httptest.NewRecorder()
h.ServeHTTP(rw, req)
if rw.Code != http.StatusUnauthorized {
t.Fatalf("stale-user token must be rejected, got %d", rw.Code)
}
}
+21
View File
@@ -0,0 +1,21 @@
package httpapi
import (
"encoding/json"
"strings"
"testing"
"github.com/vasyansk/imap-copier/internal/store"
)
func TestAccountDTOHidesPasswords(t *testing.T) {
a := store.Account{ID: 1, SrcLogin: "u", SrcPassEnc: "SECRET_ENC", DstLogin: "v", DstPassEnc: "SECRET2"}
b, _ := json.Marshal(accountDTO(a))
s := string(b)
if strings.Contains(s, "SECRET_ENC") || strings.Contains(s, "SECRET2") || strings.Contains(strings.ToLower(s), "pass") {
t.Fatalf("DTO leaks password material: %s", s)
}
if !strings.Contains(s, `"src_login":"u"`) {
t.Fatalf("DTO missing login: %s", s)
}
}
+41
View File
@@ -0,0 +1,41 @@
package httpapi
import (
"encoding/json"
"net/http"
"github.com/vasyansk/imap-copier/internal/store"
)
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
func (s *Server) handleCreateEndpoint(w http.ResponseWriter, r *http.Request) {
var e store.Endpoint
if err := json.NewDecoder(r.Body).Decode(&e); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
if e.TLSMode != "ssl" && e.TLSMode != "starttls" && e.TLSMode != "plain" {
http.Error(w, "tls_mode must be ssl|starttls|plain", http.StatusBadRequest)
return
}
id, err := s.store.CreateEndpoint(r.Context(), e)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusCreated, map[string]int64{"id": id})
}
func (s *Server) handleListEndpoints(w http.ResponseWriter, r *http.Request) {
eps, err := s.store.ListEndpoints(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, eps)
}
+30
View File
@@ -0,0 +1,30 @@
package httpapi
import "net/http"
func (s *Server) Router() http.Handler {
mux := http.NewServeMux()
// открытые
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })
mux.HandleFunc("POST /api/login", s.handleLogin)
mux.HandleFunc("POST /api/logout", s.handleLogout)
// защищённые
api := http.NewServeMux()
api.HandleFunc("GET /api/endpoints", s.handleListEndpoints)
api.HandleFunc("POST /api/endpoints", s.handleCreateEndpoint)
api.HandleFunc("GET /api/tasks", s.handleListTasks)
api.HandleFunc("POST /api/tasks", s.handleCreateTask)
api.HandleFunc("GET /api/tasks/{id}", s.handleGetTask)
api.HandleFunc("POST /api/tasks/{id}/accounts", s.handleCreateAccount)
api.HandleFunc("POST /api/tasks/{id}/import", s.handleImportCSV)
api.HandleFunc("POST /api/tasks/{id}/test", s.handleTestAccounts)
api.HandleFunc("POST /api/tasks/{id}/run", s.handleRun)
mux.Handle("/api/", s.requireAuth(api))
mux.Handle("/ws", s.requireAuth(http.HandlerFunc(s.handleWS)))
// SPA static (fallback)
mux.Handle("/", s.staticHandler())
return mux
}
+27
View File
@@ -0,0 +1,27 @@
package httpapi
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/vasyansk/imap-copier/internal/config"
)
func TestHealthzOpen(t *testing.T) {
s := &Server{cfg: config.Config{SessionSecret: []byte("x")}}
rw := httptest.NewRecorder()
s.Router().ServeHTTP(rw, httptest.NewRequest("GET", "/healthz", nil))
if rw.Code != http.StatusOK {
t.Fatalf("healthz=%d", rw.Code)
}
}
func TestTasksRequiresAuth(t *testing.T) {
s := &Server{cfg: config.Config{SessionSecret: []byte("x")}}
rw := httptest.NewRecorder()
s.Router().ServeHTTP(rw, httptest.NewRequest("GET", "/api/tasks", nil))
if rw.Code != http.StatusUnauthorized {
t.Fatalf("want 401, got %d", rw.Code)
}
}
+86
View File
@@ -0,0 +1,86 @@
package httpapi
import (
"context"
"errors"
"net/http"
"github.com/vasyansk/imap-copier/internal/crypto"
"github.com/vasyansk/imap-copier/internal/csvimport"
"github.com/vasyansk/imap-copier/internal/orchestrator"
"github.com/vasyansk/imap-copier/internal/store"
)
func (s *Server) handleImportCSV(w http.ResponseWriter, r *http.Request) {
taskID, err := pathID(r, "id")
if err != nil {
http.Error(w, "bad id", http.StatusBadRequest)
return
}
file, _, err := r.FormFile("file")
if err != nil {
http.Error(w, "file required", http.StatusBadRequest)
return
}
defer file.Close()
rows, err := csvimport.Parse(file)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
for _, row := range rows {
srcEnc, err := crypto.Encrypt(s.cfg.EncKey, []byte(row.SrcPass))
if err != nil {
http.Error(w, "encrypt", http.StatusInternalServerError)
return
}
dstEnc, err := crypto.Encrypt(s.cfg.EncKey, []byte(row.DstPass))
if err != nil {
http.Error(w, "encrypt", http.StatusInternalServerError)
return
}
if _, err := s.store.CreateAccount(r.Context(), store.Account{
TaskID: taskID, SrcLogin: row.SrcLogin, SrcPassEnc: srcEnc,
DstLogin: row.DstLogin, DstPassEnc: dstEnc,
}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
writeJSON(w, http.StatusCreated, map[string]int{"imported": len(rows)})
}
func (s *Server) handleTestAccounts(w http.ResponseWriter, r *http.Request) {
taskID, err := pathID(r, "id")
if err != nil {
http.Error(w, "bad id", http.StatusBadRequest)
return
}
// Detach from the request context: the request context is cancelled when
// this handler returns, which would otherwise kill the background test run.
ctx := context.WithoutCancel(r.Context())
go s.orch.TestAccounts(ctx, taskID) // прогресс через WS
w.WriteHeader(http.StatusAccepted)
}
func (s *Server) handleRun(w http.ResponseWriter, r *http.Request) {
taskID, err := pathID(r, "id")
if err != nil {
http.Error(w, "bad id", http.StatusBadRequest)
return
}
runID, err := s.orch.Run(r.Context(), taskID)
if errors.Is(err, orchestrator.ErrNotTested) {
http.Error(w, "accounts must pass connection tests first", http.StatusConflict)
return
}
if errors.Is(err, orchestrator.ErrAlreadyRunning) {
http.Error(w, "task is already running", http.StatusConflict)
return
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusAccepted, map[string]int64{"run_id": runID})
}
+28
View File
@@ -0,0 +1,28 @@
package httpapi
import (
"mime/multipart"
"net/http/httptest"
"strings"
"testing"
"github.com/vasyansk/imap-copier/internal/config"
)
func TestImportCSVFailsOnBadEncKey(t *testing.T) {
// EncKey wrong size => crypto.Encrypt errors => handler must NOT return success
s := &Server{cfg: config.Config{EncKey: make([]byte, 16)}}
body := &strings.Builder{}
mw := multipart.NewWriter(body)
fw, _ := mw.CreateFormFile("file", "a.csv")
fw.Write([]byte("a@x,p1,a@y,p2\n"))
mw.Close()
req := httptest.NewRequest("POST", "/api/tasks/1/import", strings.NewReader(body.String()))
req.Header.Set("Content-Type", mw.FormDataContentType())
req.SetPathValue("id", "1")
rw := httptest.NewRecorder()
s.handleImportCSV(rw, req)
if rw.Code == 200 || rw.Code == 201 {
t.Fatalf("import must fail on bad EncKey, got %d", rw.Code)
}
}
+35
View File
@@ -0,0 +1,35 @@
package httpapi
import (
"embed"
"io/fs"
"net/http"
)
//go:embed all:webdist
var webDist embed.FS
func (s *Server) staticHandler() http.Handler {
sub, err := fs.Sub(webDist, "webdist")
if err != nil {
panic(err)
}
fileServer := http.FileServer(http.FS(sub))
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// SPA fallback: если файла нет — отдать index.html
if _, err := fs.Stat(sub, trimLead(r.URL.Path)); err != nil && r.URL.Path != "/" {
r2 := r.Clone(r.Context())
r2.URL.Path = "/"
fileServer.ServeHTTP(w, r2)
return
}
fileServer.ServeHTTP(w, r)
})
}
func trimLead(p string) string {
if len(p) > 0 && p[0] == '/' {
return p[1:]
}
return p
}
+54
View File
@@ -0,0 +1,54 @@
package httpapi
import (
"encoding/json"
"net/http"
"github.com/vasyansk/imap-copier/internal/store"
)
func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) {
var t store.Task
if err := json.NewDecoder(r.Body).Decode(&t); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
id, err := s.store.CreateTask(r.Context(), t)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusCreated, map[string]int64{"id": id})
}
func (s *Server) handleListTasks(w http.ResponseWriter, r *http.Request) {
tasks, err := s.store.ListTasks(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, tasks)
}
func (s *Server) handleGetTask(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r, "id")
if err != nil {
http.Error(w, "bad id", http.StatusBadRequest)
return
}
task, err := s.store.GetTask(r.Context(), id)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
accs, err := s.store.ListAccountsByTask(r.Context(), id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
views := make([]AccountView, 0, len(accs))
for _, a := range accs {
views = append(views, accountDTO(a))
}
writeJSON(w, http.StatusOK, map[string]any{"task": task, "accounts": views})
}
+1
View File
@@ -0,0 +1 @@
<!doctype html><title>imap-copier</title>
+48
View File
@@ -0,0 +1,48 @@
package httpapi
import (
"context"
"net/http"
"strconv"
"time"
"github.com/coder/websocket"
"github.com/coder/websocket/wsjson"
)
func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
taskID, err := strconv.ParseInt(r.URL.Query().Get("task_id"), 10, 64)
if err != nil {
http.Error(w, "task_id required", http.StatusBadRequest)
return
}
c, err := websocket.Accept(w, r, nil)
if err != nil {
return
}
defer c.CloseNow()
subID, ch := s.hub.Subscribe(taskID)
defer s.hub.Unsubscribe(taskID, subID)
// websocket.Accept хайджекает соединение, поэтому r.Context() не отменяется
// при обрыве связи клиентом. CloseRead запускает фоновое чтение control-фреймов
// и отменяет возвращаемый контекст, когда соединение действительно умирает.
ctx := c.CloseRead(r.Context())
for {
select {
case ev, ok := <-ch:
if !ok {
return
}
wctx, cancel := context.WithTimeout(ctx, 5*time.Second)
err := wsjson.Write(wctx, c, ev)
cancel()
if err != nil {
return
}
case <-ctx.Done():
return
}
}
}
+63
View File
@@ -0,0 +1,63 @@
package httpapi
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/coder/websocket"
"github.com/vasyansk/imap-copier/internal/config"
"github.com/vasyansk/imap-copier/internal/crypto"
"github.com/vasyansk/imap-copier/internal/wshub"
)
func TestWSRequiresAuth(t *testing.T) {
s := &Server{cfg: config.Config{SessionSecret: []byte("x")}, hub: wshub.New()}
srv := httptest.NewServer(s.Router())
defer srv.Close()
// no cookie -> upgrade rejected (401)
_, resp, err := websocket.Dial(context.Background(), "ws"+srv.URL[4:]+"/ws?task_id=1", nil)
if err == nil {
t.Fatal("expected auth rejection")
}
if resp != nil && resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("want 401, got %d", resp.StatusCode)
}
}
func TestWSUnsubscribesOnClientDisconnect(t *testing.T) {
hub := wshub.New()
secret := []byte("sekret")
s := &Server{cfg: config.Config{AuthUser: "admin", SessionSecret: secret}, hub: hub}
srv := httptest.NewServer(s.Router())
defer srv.Close()
tok := crypto.SignSession(secret, "admin", time.Now().Add(time.Hour))
hdr := http.Header{}
hdr.Set("Cookie", cookieName+"="+tok)
ctx := context.Background()
c, _, err := websocket.Dial(ctx, "ws"+srv.URL[4:]+"/ws?task_id=7", &websocket.DialOptions{HTTPHeader: hdr})
if err != nil {
t.Fatalf("dial: %v", err)
}
// wait until subscribed
deadline := time.Now().Add(2 * time.Second)
for hub.SubscriberCount(7) == 0 {
if time.Now().After(deadline) {
t.Fatal("never subscribed")
}
time.Sleep(10 * time.Millisecond)
}
// abrupt client close -> server must detect and unsubscribe
c.CloseNow()
deadline = time.Now().Add(3 * time.Second)
for hub.SubscriberCount(7) != 0 {
if time.Now().After(deadline) {
t.Fatal("subscription leaked after client disconnect")
}
time.Sleep(20 * time.Millisecond)
}
}
+40
View File
@@ -0,0 +1,40 @@
package imapx
import (
"context"
"github.com/emersion/go-imap/v2/imapclient"
)
// ListFolders returns the mailbox names visible on an already-connected, logged-in client.
func ListFolders(c *imapclient.Client) ([]string, error) {
mboxes, err := c.List("", "*", nil).Collect()
if err != nil {
return nil, err
}
names := make([]string, 0, len(mboxes))
for _, m := range mboxes {
names = append(names, m.Mailbox)
}
return names, nil
}
func TestLogin(ctx context.Context, ep Endpoint, login, pass string) ([]string, error) {
c, err := Connect(ctx, ep)
if err != nil {
return nil, err
}
defer func() { _ = c.Logout().Wait() }()
if err := c.Login(login, pass).Wait(); err != nil {
return nil, err
}
mboxes, err := c.List("", "*", nil).Collect()
if err != nil {
return nil, err
}
names := make([]string, 0, len(mboxes))
for _, m := range mboxes {
names = append(names, m.Mailbox)
}
return names, nil
}
+153
View File
@@ -0,0 +1,153 @@
package imapx
import (
"bytes"
"context"
"fmt"
"io"
"time"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapclient"
)
// CopyDeps injects the dedup/progress hooks used by CopyFolder. APPEND to
// dst always happens before MarkMigrated is called, so a crash between the
// two only ever causes a message to be re-copied (never lost) on the next
// run.
type CopyDeps struct {
IsMigrated func(key string) (bool, error)
MarkMigrated func(folder, key string) error
OnProgress func(copied, skipped int)
}
// CopyResult summarizes the outcome of one CopyFolder run.
type CopyResult struct {
Copied int
Skipped int
Errors int
}
// CopyFolder streams messages from srcFolder on src to dstFolder on dst.
//
// The source folder is opened read-only (EXAMINE) and is never mutated:
// no \Deleted flags are set and no EXPUNGE is issued. Each message body is
// held in memory only for the duration of a single FETCH->APPEND and is
// never written to disk. Messages already migrated (per deps.IsMigrated)
// are skipped without re-fetching their bodies.
func CopyFolder(ctx context.Context, src, dst *imapclient.Client, srcFolder, dstFolder string, deps CopyDeps) (CopyResult, error) {
var res CopyResult
sel, err := src.Select(srcFolder, &imap.SelectOptions{ReadOnly: true}).Wait()
if err != nil {
return res, fmt.Errorf("examine src %q: %w", srcFolder, err)
}
if sel.NumMessages == 0 {
return res, nil
}
// 1) Collect envelope+uid+size for every message (cheap pass, no bodies).
metaSet := imap.SeqSet{imap.SeqRange{Start: 1, Stop: sel.NumMessages}}
metas, err := src.Fetch(metaSet, &imap.FetchOptions{
UID: true, Envelope: true, RFC822Size: true, Flags: true, InternalDate: true,
}).Collect()
if err != nil {
return res, fmt.Errorf("fetch meta: %w", err)
}
// dst folder must exist (idempotent create; ignore "already exists").
_ = dst.Create(dstFolder, nil).Wait()
for _, m := range metas {
if err := ctx.Err(); err != nil {
return res, err
}
key := MessageKey(m.Envelope, m.RFC822Size)
already, err := deps.IsMigrated(key)
if err != nil {
res.Errors++
continue
}
if already {
res.Skipped++
if deps.OnProgress != nil {
deps.OnProgress(res.Copied, res.Skipped)
}
continue
}
if err := streamOne(src, dst, dstFolder, m.UID, m.Flags, m.InternalDate); err != nil {
res.Errors++
continue
}
if err := deps.MarkMigrated(dstFolder, key); err != nil {
res.Errors++
continue
}
res.Copied++
if deps.OnProgress != nil {
deps.OnProgress(res.Copied, res.Skipped)
}
}
return res, nil
}
// streamOne FETCHes BODY[] for one message and APPENDs it into dst without
// spooling to disk. The body is buffered in RAM only for the duration of
// this single FETCH->APPEND round trip.
func streamOne(src, dst *imapclient.Client, dstFolder string, uid imap.UID, flags []imap.Flag, internalDate time.Time) error {
bodySection := &imap.FetchItemBodySection{}
fetchCmd := src.Fetch(imap.UIDSetNum(uid), &imap.FetchOptions{
BodySection: []*imap.FetchItemBodySection{bodySection},
})
defer fetchCmd.Close()
msg := fetchCmd.Next()
if msg == nil {
return fmt.Errorf("no message for uid %v", uid)
}
var body []byte
for {
item := msg.Next()
if item == nil {
break
}
if d, ok := item.(imapclient.FetchItemDataBodySection); ok {
b, err := io.ReadAll(d.Literal)
if err != nil {
return err
}
body = b
}
}
if err := fetchCmd.Close(); err != nil {
return err
}
if body == nil {
return fmt.Errorf("empty body uid %v", uid)
}
appendCmd := dst.Append(dstFolder, int64(len(body)), &imap.AppendOptions{Flags: keepFlags(flags), Time: internalDate})
if _, err := io.Copy(appendCmd, bytes.NewReader(body)); err != nil {
return err
}
if err := appendCmd.Close(); err != nil {
return err
}
_, err := appendCmd.Wait()
return err
}
// keepFlags drops \Recent: it cannot be set via APPEND. go-imap v2 beta.8
// no longer defines an imap.FlagRecent constant (RFC 9051 dropped \Recent
// from IMAP4rev2), so match it by its literal wire form instead.
func keepFlags(flags []imap.Flag) []imap.Flag {
out := make([]imap.Flag, 0, len(flags))
for _, f := range flags {
if f == "\\Recent" {
continue
}
out = append(out, f)
}
return out
}
+220
View File
@@ -0,0 +1,220 @@
package imapx
import (
"context"
"fmt"
"testing"
"time"
"github.com/emersion/go-imap/v2"
)
// seedInbox logs in as login/pass and APPENDs n minimal messages with unique
// Message-IDs into INBOX via a dedicated connection.
func seedInbox(t *testing.T, ep Endpoint, login, pass string, n int) {
t.Helper()
ctx := context.Background()
c, err := Connect(ctx, ep)
if err != nil {
t.Fatalf("seedInbox connect: %v", err)
}
defer func() { _ = c.Logout().Wait() }()
if err := c.Login(login, pass).Wait(); err != nil {
t.Fatalf("seedInbox login: %v", err)
}
for i := 0; i < n; i++ {
msg := fmt.Sprintf(
"From: sender@localhost\r\nTo: %s\r\nSubject: seed %d\r\nMessage-Id: <seed-%d-%p@localhost>\r\n\r\nBody %d\r\n",
login, i, i, &i, i,
)
buf := []byte(msg)
appendCmd := c.Append("INBOX", int64(len(buf)), nil)
if _, err := appendCmd.Write(buf); err != nil {
t.Fatalf("seedInbox write %d: %v", i, err)
}
if err := appendCmd.Close(); err != nil {
t.Fatalf("seedInbox close %d: %v", i, err)
}
if _, err := appendCmd.Wait(); err != nil {
t.Fatalf("seedInbox append %d: %v", i, err)
}
}
}
// seedInboxWithDate APPENDs a single message with a given subject and a
// KNOWN IMAP internal date (via AppendOptions.Time), so the test can assert
// the date survives the copy instead of silently becoming "now" on dst.
func seedInboxWithDate(t *testing.T, ep Endpoint, login, pass, subject string, when time.Time) {
t.Helper()
ctx := context.Background()
c, err := Connect(ctx, ep)
if err != nil {
t.Fatalf("seedInboxWithDate connect: %v", err)
}
defer func() { _ = c.Logout().Wait() }()
if err := c.Login(login, pass).Wait(); err != nil {
t.Fatalf("seedInboxWithDate login: %v", err)
}
msg := fmt.Sprintf(
"From: sender@localhost\r\nTo: %s\r\nSubject: %s\r\nMessage-Id: <%s@localhost>\r\n\r\nBody\r\n",
login, subject, subject,
)
buf := []byte(msg)
appendCmd := c.Append("INBOX", int64(len(buf)), &imap.AppendOptions{Time: when})
if _, err := appendCmd.Write(buf); err != nil {
t.Fatalf("seedInboxWithDate write: %v", err)
}
if err := appendCmd.Close(); err != nil {
t.Fatalf("seedInboxWithDate close: %v", err)
}
if _, err := appendCmd.Wait(); err != nil {
t.Fatalf("seedInboxWithDate append: %v", err)
}
}
// fetchInternalDateBySubject connects, selects INBOX and returns the
// InternalDate of the first message whose Envelope.Subject matches.
func fetchInternalDateBySubject(t *testing.T, ep Endpoint, login, pass, subject string) time.Time {
t.Helper()
ctx := context.Background()
c, err := Connect(ctx, ep)
if err != nil {
t.Fatalf("fetchInternalDateBySubject connect: %v", err)
}
defer func() { _ = c.Logout().Wait() }()
if err := c.Login(login, pass).Wait(); err != nil {
t.Fatalf("fetchInternalDateBySubject login: %v", err)
}
sel, err := c.Select("INBOX", &imap.SelectOptions{ReadOnly: true}).Wait()
if err != nil {
t.Fatalf("fetchInternalDateBySubject select: %v", err)
}
if sel.NumMessages == 0 {
t.Fatalf("fetchInternalDateBySubject: INBOX empty")
}
set := imap.SeqSet{imap.SeqRange{Start: 1, Stop: sel.NumMessages}}
msgs, err := c.Fetch(set, &imap.FetchOptions{
Envelope: true, InternalDate: true,
}).Collect()
if err != nil {
t.Fatalf("fetchInternalDateBySubject fetch: %v", err)
}
for _, m := range msgs {
if m.Envelope != nil && m.Envelope.Subject == subject {
return m.InternalDate
}
}
t.Fatalf("fetchInternalDateBySubject: subject %q not found among %d messages", subject, len(msgs))
return time.Time{}
}
// TestCopyFolderPreservesInternalDate proves CopyFolder threads the source
// message's IMAP internal date through to APPEND on dst, instead of letting
// dst stamp it with "now" at APPEND time.
func TestCopyFolderPreservesInternalDate(t *testing.T) {
ep := testEP(t)
ctx := context.Background()
const subject = "datecheck"
knownTime := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC)
seedInboxWithDate(t, ep, "datesrc@localhost", "p", subject, knownTime)
src, err := Connect(ctx, ep)
if err != nil {
t.Fatal(err)
}
defer func() { _ = src.Logout().Wait() }()
if err := src.Login("datesrc@localhost", "p").Wait(); err != nil {
t.Fatal(err)
}
dst, err := Connect(ctx, ep)
if err != nil {
t.Fatal(err)
}
defer func() { _ = dst.Logout().Wait() }()
if err := dst.Login("datedst@localhost", "p").Wait(); err != nil {
t.Fatal(err)
}
deps := CopyDeps{
IsMigrated: func(string) (bool, error) { return false, nil },
MarkMigrated: func(_, _ string) error { return nil },
OnProgress: func(_, _ int) {},
}
r, err := CopyFolder(ctx, src, dst, "INBOX", "INBOX", deps)
if err != nil {
t.Fatalf("CopyFolder: %v", err)
}
if r.Copied != 1 {
t.Fatalf("copied=%d want 1", r.Copied)
}
got := fetchInternalDateBySubject(t, ep, "datedst@localhost", "p", subject).UTC().Truncate(time.Second)
want := knownTime.Truncate(time.Second)
if !got.Equal(want) {
t.Fatalf("internal date not preserved: got %v want %v", got, want)
}
}
// Требует два ящика на greenmail. Первый запуск копирует N, второй — 0 (все skipped).
func TestCopyFolderIdempotent(t *testing.T) {
ep := testEP(t) // plain greenmail
ctx := context.Background()
// подготовка: APPEND 2 письма в INBOX источника через отдельное соединение
seedInbox(t, ep, "src@localhost", "p", 2)
src, err := Connect(ctx, ep)
if err != nil {
t.Fatal(err)
}
defer func() { _ = src.Logout().Wait() }()
if err := src.Login("src@localhost", "p").Wait(); err != nil {
t.Fatal(err)
}
dst, err := Connect(ctx, ep)
if err != nil {
t.Fatal(err)
}
defer func() { _ = dst.Logout().Wait() }()
if err := dst.Login("dst@localhost", "p").Wait(); err != nil {
t.Fatal(err)
}
seen := map[string]bool{}
deps := CopyDeps{
IsMigrated: func(k string) (bool, error) { return seen[k], nil },
MarkMigrated: func(_, k string) error { seen[k] = true; return nil },
OnProgress: func(_, _ int) {},
}
r1, err := CopyFolder(ctx, src, dst, "INBOX", "INBOX", deps)
if err != nil {
t.Fatalf("run1: %v", err)
}
if r1.Copied != 2 {
t.Fatalf("run1 copied=%d want 2", r1.Copied)
}
r2, err := CopyFolder(ctx, src, dst, "INBOX", "INBOX", deps)
if err != nil {
t.Fatalf("run2: %v", err)
}
if r2.Copied != 0 || r2.Skipped != 2 {
t.Fatalf("run2 copied=%d skipped=%d want 0/2", r2.Copied, r2.Skipped)
}
}
+67
View File
@@ -0,0 +1,67 @@
package imapx
import (
"context"
"crypto/tls"
"fmt"
"time"
"github.com/emersion/go-imap/v2/imapclient"
)
type Endpoint struct {
Host string
Port int
TLSMode string // ssl | starttls | plain
}
func (e Endpoint) addr() string { return fmt.Sprintf("%s:%d", e.Host, e.Port) }
func dialOnce(ep Endpoint) (*imapclient.Client, error) {
switch ep.TLSMode {
case "ssl":
return imapclient.DialTLS(ep.addr(), &imapclient.Options{
TLSConfig: &tls.Config{ServerName: ep.Host},
})
case "starttls":
return imapclient.DialStartTLS(ep.addr(), &imapclient.Options{
TLSConfig: &tls.Config{ServerName: ep.Host},
})
case "plain":
return imapclient.DialInsecure(ep.addr(), nil)
default:
return nil, fmt.Errorf("unknown tls_mode %q", ep.TLSMode)
}
}
func Connect(ctx context.Context, ep Endpoint) (*imapclient.Client, error) {
const attempts = 3
var lastErr error
for i := 0; i < attempts; i++ {
if err := ctx.Err(); err != nil {
return nil, err
}
c, err := dialOnce(ep)
if err == nil {
return c, nil
}
lastErr = err
if i < attempts-1 {
backoff := time.Duration(200*(i+1)) * time.Millisecond
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(backoff):
}
}
}
return nil, lastErr
}
func TestEndpoint(ctx context.Context, ep Endpoint) error {
c, err := Connect(ctx, ep)
if err != nil {
return err
}
return c.Logout().Wait()
}
+51
View File
@@ -0,0 +1,51 @@
package imapx
import (
"context"
"os"
"strconv"
"testing"
)
func testEP(t *testing.T) Endpoint {
host := os.Getenv("TEST_IMAP_HOST")
if host == "" {
t.Skip("TEST_IMAP_HOST not set")
}
port, _ := strconv.Atoi(os.Getenv("TEST_IMAP_PORT"))
return Endpoint{Host: host, Port: port, TLSMode: "plain"}
}
func TestTestEndpointOK(t *testing.T) {
ep := testEP(t)
if err := TestEndpoint(context.Background(), ep); err != nil {
t.Fatalf("TestEndpoint: %v", err)
}
}
func TestTestLoginListsFolders(t *testing.T) {
ep := testEP(t)
// greenmail auto-creates users on first login
folders, err := TestLogin(context.Background(), ep, "user1@localhost", "pass1")
if err != nil {
t.Fatalf("TestLogin: %v", err)
}
found := false
for _, f := range folders {
if f == "INBOX" {
found = true
}
}
if !found {
t.Fatalf("INBOX not in folders: %v", folders)
}
}
func TestConnectHonorsCancelledContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := Connect(ctx, Endpoint{Host: "10.255.255.1", Port: 993, TLSMode: "ssl"})
if err == nil {
t.Fatal("expected error for cancelled context")
}
}
+37
View File
@@ -0,0 +1,37 @@
package imapx
import (
"crypto/md5"
"fmt"
"strings"
"github.com/emersion/go-imap/v2"
)
func MessageKey(env *imap.Envelope, size int64) string {
if env != nil && env.MessageID != "" {
return env.MessageID
}
var b strings.Builder
if env != nil {
b.WriteString(addrList(env.From))
b.WriteByte('|')
b.WriteString(addrList(env.To))
b.WriteByte('|')
b.WriteString(env.Subject)
b.WriteByte('|')
b.WriteString(env.Date.UTC().Format("2006-01-02T15:04:05Z"))
}
b.WriteByte('|')
fmt.Fprintf(&b, "%d", size)
sum := md5.Sum([]byte(b.String()))
return fmt.Sprintf("h:%x", sum)
}
func addrList(addrs []imap.Address) string {
parts := make([]string, 0, len(addrs))
for _, a := range addrs {
parts = append(parts, a.Mailbox+"@"+a.Host)
}
return strings.Join(parts, ",")
}
+32
View File
@@ -0,0 +1,32 @@
package imapx
import (
"testing"
"time"
"github.com/emersion/go-imap/v2"
)
func TestMessageKeyPrefersMessageID(t *testing.T) {
env := &imap.Envelope{MessageID: "<abc@host>"}
if got := MessageKey(env, 100); got != "<abc@host>" {
t.Fatalf("got %q, want <abc@host>", got)
}
}
func TestMessageKeyFallbackStable(t *testing.T) {
env := &imap.Envelope{
Subject: "Hi",
Date: time.Unix(1700000000, 0).UTC(),
From: []imap.Address{{Mailbox: "a", Host: "x.com"}},
To: []imap.Address{{Mailbox: "b", Host: "y.com"}},
}
k1 := MessageKey(env, 42)
k2 := MessageKey(env, 42)
if k1 != k2 {
t.Fatal("fallback key must be deterministic")
}
if MessageKey(env, 43) == k1 {
t.Fatal("different size must change key")
}
}
+251
View File
@@ -0,0 +1,251 @@
package orchestrator
import (
"context"
"errors"
"log/slog"
"sync"
"github.com/vasyansk/imap-copier/internal/crypto"
"github.com/vasyansk/imap-copier/internal/imapx"
"github.com/vasyansk/imap-copier/internal/store"
"github.com/vasyansk/imap-copier/internal/wshub"
)
var ErrNotTested = errors.New("accounts not fully tested")
var ErrAlreadyRunning = errors.New("task already running")
type Orchestrator struct {
store *store.Store
hub *wshub.Hub
encKey []byte
concurrency int
}
func New(s *store.Store, hub *wshub.Hub, encKey []byte, concurrency int) *Orchestrator {
return &Orchestrator{store: s, hub: hub, encKey: encKey, concurrency: concurrency}
}
func gateOK(accs []store.Account) bool {
if len(accs) == 0 {
return false
}
for _, a := range accs {
if a.TestSrcStatus != "ok" || a.TestDstStatus != "ok" {
return false
}
}
return true
}
func (o *Orchestrator) endpoints(ctx context.Context, task store.Task) (imapx.Endpoint, imapx.Endpoint, error) {
src, err := o.store.GetEndpoint(ctx, task.SrcEndpointID)
if err != nil {
return imapx.Endpoint{}, imapx.Endpoint{}, err
}
dst, err := o.store.GetEndpoint(ctx, task.DstEndpointID)
if err != nil {
return imapx.Endpoint{}, imapx.Endpoint{}, err
}
toEP := func(e store.Endpoint) imapx.Endpoint {
return imapx.Endpoint{Host: e.Host, Port: e.Port, TLSMode: e.TLSMode}
}
return toEP(src), toEP(dst), nil
}
func (o *Orchestrator) TestAccounts(ctx context.Context, taskID int64) error {
task, err := o.store.GetTask(ctx, taskID)
if err != nil {
return err
}
srcEP, dstEP, err := o.endpoints(ctx, task)
if err != nil {
return err
}
accs, err := o.store.ListAccountsByTask(ctx, taskID)
if err != nil {
return err
}
for _, a := range accs {
o.testSide(ctx, srcEP, a.ID, "src", a.SrcLogin, a.SrcPassEnc, taskID)
o.testSide(ctx, dstEP, a.ID, "dst", a.DstLogin, a.DstPassEnc, taskID)
}
return nil
}
func (o *Orchestrator) testSide(ctx context.Context, ep imapx.Endpoint, accID int64, side, login, passEnc string, taskID int64) {
status := "ok"
pass, err := crypto.Decrypt(o.encKey, passEnc)
if err == nil {
_, err = imapx.TestLogin(ctx, ep, login, string(pass))
}
if err != nil {
status = "fail"
slog.Warn("account test failed", "account", accID, "side", side, "err", err)
}
_ = o.store.SetAccountTestStatus(ctx, accID, side, status)
o.hub.Publish(wshub.Event{Type: "account_test", TaskID: taskID,
Data: map[string]any{"account_id": accID, "side": side, "status": status}})
}
func (o *Orchestrator) Run(ctx context.Context, taskID int64) (int64, error) {
task, err := o.store.GetTask(ctx, taskID)
if err != nil {
return 0, err
}
accs, err := o.store.ListAccountsByTask(ctx, taskID)
if err != nil {
return 0, err
}
if !gateOK(accs) {
return 0, ErrNotTested
}
acquired, err := o.store.TryMarkTaskRunning(ctx, taskID)
if err != nil {
return 0, err
}
if !acquired {
return 0, ErrAlreadyRunning
}
srcEP, dstEP, err := o.endpoints(ctx, task)
if err != nil {
_ = o.store.SetTaskStatus(ctx, taskID, "error")
return 0, err
}
runID, err := o.store.CreateRun(ctx, taskID)
if err != nil {
_ = o.store.SetTaskStatus(ctx, taskID, "error")
return 0, err
}
o.hub.Publish(wshub.Event{Type: "run_started", TaskID: taskID, Data: map[string]any{"run_id": runID}})
go o.runAll(context.WithoutCancel(ctx), task, runID, accs, srcEP, dstEP)
return runID, nil
}
func (o *Orchestrator) runAll(ctx context.Context, task store.Task, runID int64, accs []store.Account, srcEP, dstEP imapx.Endpoint) {
defer func() {
if r := recover(); r != nil {
slog.Error("run coordinator panicked", "task", task.ID, "run", runID, "panic", r)
_ = o.store.FinishRun(ctx, runID, "error", 0, 0, 0)
_ = o.store.SetTaskStatus(ctx, task.ID, "error")
}
}()
var (
mu sync.Mutex
totCopied, totSkipped, totErr int64
)
sem := make(chan struct{}, o.concurrency)
var wg sync.WaitGroup
for _, a := range accs {
wg.Add(1)
sem <- struct{}{}
go func(a store.Account) {
defer wg.Done()
defer func() { <-sem }()
defer func() {
if r := recover(); r != nil {
slog.Error("account worker panicked", "task", task.ID, "account", a.ID, "panic", r)
_ = o.store.SetAccountStatus(ctx, a.ID, "error")
o.hub.Publish(wshub.Event{Type: "error", TaskID: task.ID,
Data: map[string]any{"account_id": a.ID, "error": "internal panic"}})
mu.Lock()
totErr++
mu.Unlock()
}
}()
c, s, e := o.runAccount(ctx, task, runID, a, srcEP, dstEP)
mu.Lock()
totCopied += c
totSkipped += s
totErr += e
mu.Unlock()
}(a)
}
wg.Wait()
status := "done"
if totErr > 0 {
status = "done_with_errors"
}
_ = o.store.FinishRun(ctx, runID, status, totCopied, totSkipped, totErr)
_ = o.store.SetTaskStatus(ctx, task.ID, status)
o.hub.Publish(wshub.Event{Type: "run_done", TaskID: task.ID,
Data: map[string]any{"run_id": runID, "copied": totCopied, "skipped": totSkipped, "errors": totErr}})
}
func (o *Orchestrator) runAccount(ctx context.Context, task store.Task, runID int64, a store.Account, srcEP, dstEP imapx.Endpoint) (int64, int64, int64) {
o.hub.Publish(wshub.Event{Type: "account_started", TaskID: task.ID, Data: map[string]any{"account_id": a.ID}})
_ = o.store.SetAccountStatus(ctx, a.ID, "running")
srcPass, err := crypto.Decrypt(o.encKey, a.SrcPassEnc)
if err != nil {
return o.accountFailed(ctx, task.ID, a.ID, err)
}
dstPass, err := crypto.Decrypt(o.encKey, a.DstPassEnc)
if err != nil {
return o.accountFailed(ctx, task.ID, a.ID, err)
}
src, err := imapx.Connect(ctx, srcEP)
if err != nil {
return o.accountFailed(ctx, task.ID, a.ID, err)
}
defer func() { _ = src.Logout().Wait() }()
if err := src.Login(a.SrcLogin, string(srcPass)).Wait(); err != nil {
return o.accountFailed(ctx, task.ID, a.ID, err)
}
dst, err := imapx.Connect(ctx, dstEP)
if err != nil {
return o.accountFailed(ctx, task.ID, a.ID, err)
}
defer func() { _ = dst.Logout().Wait() }()
if err := dst.Login(a.DstLogin, string(dstPass)).Wait(); err != nil {
return o.accountFailed(ctx, task.ID, a.ID, err)
}
folders, err := imapx.ListFolders(src)
if err != nil {
return o.accountFailed(ctx, task.ID, a.ID, err)
}
var copied, skipped, errs int64
deps := imapx.CopyDeps{
IsMigrated: func(k string) (bool, error) { return o.store.IsMigrated(ctx, a.ID, k) },
MarkMigrated: func(folder, k string) error { return o.store.MarkMigrated(ctx, a.ID, folder, k) },
OnProgress: func(c, s int) {
o.hub.Publish(wshub.Event{Type: "progress", TaskID: task.ID,
Data: map[string]any{"account_id": a.ID, "copied": c, "skipped": s}})
},
}
for _, folder := range folders {
dstFolder := folder
if m, ok := task.FolderMapping[folder]; ok {
dstFolder = m
}
res, err := imapx.CopyFolder(ctx, src, dst, folder, dstFolder, deps)
if err != nil {
slog.Warn("folder copy error", "account", a.ID, "folder", folder, "err", err)
errs++
}
copied += int64(res.Copied)
skipped += int64(res.Skipped)
errs += int64(res.Errors)
_ = o.store.IncAccountCounters(ctx, a.ID, int64(res.Copied), int64(res.Skipped), int64(res.Errors))
}
_ = o.store.SetAccountStatus(ctx, a.ID, "done")
o.hub.Publish(wshub.Event{Type: "account_done", TaskID: task.ID,
Data: map[string]any{"account_id": a.ID, "copied": copied, "skipped": skipped, "errors": errs}})
slog.Info("account copied", "account", a.ID, "copied", copied, "skipped", skipped, "errors", errs)
return copied, skipped, errs
}
func (o *Orchestrator) accountFailed(ctx context.Context, taskID, accID int64, err error) (int64, int64, int64) {
slog.Error("account failed", "account", accID, "err", err)
_ = o.store.SetAccountStatus(ctx, accID, "error")
o.hub.Publish(wshub.Event{Type: "error", TaskID: taskID,
Data: map[string]any{"account_id": accID, "error": err.Error()}})
return 0, 0, 1
}
@@ -0,0 +1,24 @@
package orchestrator
import (
"testing"
"github.com/vasyansk/imap-copier/internal/store"
)
func TestGateOK(t *testing.T) {
ok := []store.Account{
{TestSrcStatus: "ok", TestDstStatus: "ok"},
{TestSrcStatus: "ok", TestDstStatus: "ok"},
}
if !gateOK(ok) {
t.Fatal("all ok must pass gate")
}
bad := []store.Account{{TestSrcStatus: "ok", TestDstStatus: "fail"}}
if gateOK(bad) {
t.Fatal("any non-ok must fail gate")
}
if gateOK(nil) {
t.Fatal("empty accounts must fail gate")
}
}
+74
View File
@@ -0,0 +1,74 @@
package store
import (
"context"
"fmt"
)
type Account struct {
ID int64
TaskID int64
SrcLogin string
SrcPassEnc string
DstLogin string
DstPassEnc string
TestSrcStatus string
TestDstStatus string
Status string
Copied int64
Skipped int64
Errors int64
}
func (s *Store) CreateAccount(ctx context.Context, a Account) (int64, error) {
var id int64
err := s.Pool.QueryRow(ctx,
`INSERT INTO accounts (task_id, src_login, src_pass_enc, dst_login, dst_pass_enc)
VALUES ($1,$2,$3,$4,$5) RETURNING id`,
a.TaskID, a.SrcLogin, a.SrcPassEnc, a.DstLogin, a.DstPassEnc).Scan(&id)
return id, err
}
func (s *Store) ListAccountsByTask(ctx context.Context, taskID int64) ([]Account, error) {
rows, err := s.Pool.Query(ctx,
`SELECT id, task_id, src_login, src_pass_enc, dst_login, dst_pass_enc,
test_src_status, test_dst_status, status, copied_count, skipped_count, error_count
FROM accounts WHERE task_id=$1 ORDER BY id`, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Account
for rows.Next() {
var a Account
if err := rows.Scan(&a.ID, &a.TaskID, &a.SrcLogin, &a.SrcPassEnc, &a.DstLogin, &a.DstPassEnc,
&a.TestSrcStatus, &a.TestDstStatus, &a.Status, &a.Copied, &a.Skipped, &a.Errors); err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
// side = "src" | "dst"
func (s *Store) SetAccountTestStatus(ctx context.Context, id int64, side, status string) error {
col := "test_src_status"
if side == "dst" {
col = "test_dst_status"
}
_, err := s.Pool.Exec(ctx, fmt.Sprintf(`UPDATE accounts SET %s=$2 WHERE id=$1`, col), id, status)
return err
}
func (s *Store) SetAccountStatus(ctx context.Context, id int64, status string) error {
_, err := s.Pool.Exec(ctx, `UPDATE accounts SET status=$2 WHERE id=$1`, id, status)
return err
}
func (s *Store) IncAccountCounters(ctx context.Context, id, copied, skipped, errs int64) error {
_, err := s.Pool.Exec(ctx,
`UPDATE accounts SET copied_count=copied_count+$2,
skipped_count=skipped_count+$3, error_count=error_count+$4 WHERE id=$1`,
id, copied, skipped, errs)
return err
}
+30
View File
@@ -0,0 +1,30 @@
package store
import (
"context"
"testing"
)
func TestMigratedIdempotency(t *testing.T) {
s := testStore(t)
ctx := context.Background()
epSrc, _ := s.CreateEndpoint(ctx, Endpoint{RoleLabel: "src", Host: "a", Port: 993, TLSMode: "ssl"})
epDst, _ := s.CreateEndpoint(ctx, Endpoint{RoleLabel: "dst", Host: "b", Port: 993, TLSMode: "ssl"})
taskID, _ := s.CreateTask(ctx, Task{Name: "t", SrcEndpointID: epSrc, DstEndpointID: epDst})
accID, _ := s.CreateAccount(ctx, Account{TaskID: taskID, SrcLogin: "u", SrcPassEnc: "x", DstLogin: "u2", DstPassEnc: "y"})
if err := s.MarkMigrated(ctx, accID, "INBOX", "<msg-1>"); err != nil {
t.Fatalf("mark: %v", err)
}
if err := s.MarkMigrated(ctx, accID, "INBOX", "<msg-1>"); err != nil {
t.Fatalf("second mark must not error (ON CONFLICT): %v", err)
}
ok, err := s.IsMigrated(ctx, accID, "<msg-1>")
if err != nil || !ok {
t.Fatalf("IsMigrated = %v,%v want true,nil", ok, err)
}
absent, _ := s.IsMigrated(ctx, accID, "<msg-2>")
if absent {
t.Fatal("unknown key must be false")
}
}
+46
View File
@@ -0,0 +1,46 @@
package store
import "context"
type Endpoint struct {
ID int64 `json:"id"`
RoleLabel string `json:"role_label"`
Host string `json:"host"`
Port int `json:"port"`
TLSMode string `json:"tls_mode"`
}
func (s *Store) CreateEndpoint(ctx context.Context, e Endpoint) (int64, error) {
var id int64
err := s.Pool.QueryRow(ctx,
`INSERT INTO endpoints (role_label, host, port, tls_mode)
VALUES ($1,$2,$3,$4) RETURNING id`,
e.RoleLabel, e.Host, e.Port, e.TLSMode).Scan(&id)
return id, err
}
func (s *Store) GetEndpoint(ctx context.Context, id int64) (Endpoint, error) {
var e Endpoint
err := s.Pool.QueryRow(ctx,
`SELECT id, role_label, host, port, tls_mode FROM endpoints WHERE id=$1`, id).
Scan(&e.ID, &e.RoleLabel, &e.Host, &e.Port, &e.TLSMode)
return e, err
}
func (s *Store) ListEndpoints(ctx context.Context) ([]Endpoint, error) {
rows, err := s.Pool.Query(ctx,
`SELECT id, role_label, host, port, tls_mode FROM endpoints ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Endpoint
for rows.Next() {
var e Endpoint
if err := rows.Scan(&e.ID, &e.RoleLabel, &e.Host, &e.Port, &e.TLSMode); err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}
+35
View File
@@ -0,0 +1,35 @@
package store
import (
"encoding/json"
"strings"
"testing"
)
func TestEndpointJSONRoundTrip(t *testing.T) {
var e Endpoint
if err := json.Unmarshal([]byte(`{"role_label":"src","host":"h","port":993,"tls_mode":"ssl"}`), &e); err != nil {
t.Fatal(err)
}
if e.RoleLabel != "src" || e.Host != "h" || e.Port != 993 || e.TLSMode != "ssl" {
t.Fatalf("decode failed: %+v", e)
}
b, _ := json.Marshal(e)
if !strings.Contains(string(b), `"tls_mode":"ssl"`) || !strings.Contains(string(b), `"role_label":"src"`) {
t.Fatalf("marshal not snake_case: %s", b)
}
}
func TestTaskJSONRoundTrip(t *testing.T) {
var tk Task
if err := json.Unmarshal([]byte(`{"name":"n","src_endpoint_id":1,"dst_endpoint_id":2}`), &tk); err != nil {
t.Fatal(err)
}
if tk.Name != "n" || tk.SrcEndpointID != 1 || tk.DstEndpointID != 2 {
t.Fatalf("decode failed: %+v", tk)
}
b, _ := json.Marshal(tk)
if !strings.Contains(string(b), `"src_endpoint_id":1`) {
t.Fatalf("marshal not snake_case: %s", b)
}
}
+30
View File
@@ -0,0 +1,30 @@
package store
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
)
func (s *Store) IsMigrated(ctx context.Context, accountID int64, key string) (bool, error) {
var one int
err := s.Pool.QueryRow(ctx,
`SELECT 1 FROM migrated_messages WHERE account_id=$1 AND message_key=$2`,
accountID, key).Scan(&one)
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
func (s *Store) MarkMigrated(ctx context.Context, accountID int64, folder, key string) error {
_, err := s.Pool.Exec(ctx,
`INSERT INTO migrated_messages (account_id, folder, message_key)
VALUES ($1,$2,$3) ON CONFLICT (account_id, message_key) DO NOTHING`,
accountID, folder, key)
return err
}
+27
View File
@@ -0,0 +1,27 @@
package store
import "context"
type Run struct {
ID int64
TaskID int64
Status string
TotalCopied int64
TotalSkipped int64
TotalErrors int64
}
func (s *Store) CreateRun(ctx context.Context, taskID int64) (int64, error) {
var id int64
err := s.Pool.QueryRow(ctx,
`INSERT INTO runs (task_id) VALUES ($1) RETURNING id`, taskID).Scan(&id)
return id, err
}
func (s *Store) FinishRun(ctx context.Context, id int64, status string, copied, skipped, errs int64) error {
_, err := s.Pool.Exec(ctx,
`UPDATE runs SET status=$2, finished_at=now(),
total_copied=$3, total_skipped=$4, total_errors=$5 WHERE id=$1`,
id, status, copied, skipped, errs)
return err
}
+23
View File
@@ -0,0 +1,23 @@
package store
import (
"context"
"github.com/jackc/pgx/v5/pgxpool"
)
type Store struct {
Pool *pgxpool.Pool
}
func New(ctx context.Context, dsn string) (*Store, error) {
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
return nil, err
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, err
}
return &Store{Pool: pool}, nil
}
+60
View File
@@ -0,0 +1,60 @@
package store
import (
"context"
"os"
"testing"
)
func testStore(t *testing.T) *Store {
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set")
}
s, err := New(context.Background(), dsn)
if err != nil {
t.Fatalf("New: %v", err)
}
t.Cleanup(func() {
s.Pool.Exec(context.Background(),
`TRUNCATE endpoints, tasks, accounts, runs, migrated_messages RESTART IDENTITY CASCADE`)
s.Pool.Close()
})
return s
}
func TestCreateAndGetEndpoint(t *testing.T) {
s := testStore(t)
ctx := context.Background()
id, err := s.CreateEndpoint(ctx, Endpoint{RoleLabel: "src", Host: "imap.a.com", Port: 993, TLSMode: "ssl"})
if err != nil {
t.Fatalf("create: %v", err)
}
got, err := s.GetEndpoint(ctx, id)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Host != "imap.a.com" || got.Port != 993 {
t.Fatalf("got %+v", got)
}
}
func TestListEndpointsOrdered(t *testing.T) {
s := testStore(t)
ctx := context.Background()
id1, _ := s.CreateEndpoint(ctx, Endpoint{RoleLabel: "src", Host: "a.com", Port: 993, TLSMode: "ssl"})
id2, _ := s.CreateEndpoint(ctx, Endpoint{RoleLabel: "dst", Host: "b.com", Port: 143, TLSMode: "starttls"})
eps, err := s.ListEndpoints(ctx)
if err != nil {
t.Fatalf("list: %v", err)
}
if len(eps) != 2 {
t.Fatalf("len=%d want 2", len(eps))
}
if eps[0].ID != id1 || eps[1].ID != id2 {
t.Fatalf("order wrong: %d,%d want %d,%d", eps[0].ID, eps[1].ID, id1, id2)
}
if eps[1].TLSMode != "starttls" {
t.Fatalf("eps[1].TLSMode=%q want starttls", eps[1].TLSMode)
}
}
+68
View File
@@ -0,0 +1,68 @@
package store
import "context"
type Task struct {
ID int64 `json:"id"`
Name string `json:"name"`
SrcEndpointID int64 `json:"src_endpoint_id"`
DstEndpointID int64 `json:"dst_endpoint_id"`
Status string `json:"status"`
FolderMapping map[string]string `json:"folder_mapping"`
}
func (s *Store) CreateTask(ctx context.Context, t Task) (int64, error) {
if t.FolderMapping == nil {
t.FolderMapping = map[string]string{}
}
var id int64
err := s.Pool.QueryRow(ctx,
`INSERT INTO tasks (name, src_endpoint_id, dst_endpoint_id, folder_mapping)
VALUES ($1,$2,$3,$4) RETURNING id`,
t.Name, t.SrcEndpointID, t.DstEndpointID, t.FolderMapping).Scan(&id)
return id, err
}
func (s *Store) GetTask(ctx context.Context, id int64) (Task, error) {
var t Task
err := s.Pool.QueryRow(ctx,
`SELECT id, name, src_endpoint_id, dst_endpoint_id, status, folder_mapping
FROM tasks WHERE id=$1`, id).
Scan(&t.ID, &t.Name, &t.SrcEndpointID, &t.DstEndpointID, &t.Status, &t.FolderMapping)
return t, err
}
func (s *Store) ListTasks(ctx context.Context) ([]Task, error) {
rows, err := s.Pool.Query(ctx,
`SELECT id, name, src_endpoint_id, dst_endpoint_id, status, folder_mapping
FROM tasks ORDER BY id DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Task
for rows.Next() {
var t Task
if err := rows.Scan(&t.ID, &t.Name, &t.SrcEndpointID, &t.DstEndpointID, &t.Status, &t.FolderMapping); err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
func (s *Store) SetTaskStatus(ctx context.Context, id int64, status string) error {
_, err := s.Pool.Exec(ctx, `UPDATE tasks SET status=$2 WHERE id=$1`, id, status)
return err
}
// TryMarkTaskRunning atomically sets status='running' only if the task is not already running.
// Returns true if this call acquired the run (status was not 'running' before), false otherwise.
func (s *Store) TryMarkTaskRunning(ctx context.Context, id int64) (bool, error) {
ct, err := s.Pool.Exec(ctx,
`UPDATE tasks SET status='running' WHERE id=$1 AND status<>'running'`, id)
if err != nil {
return false, err
}
return ct.RowsAffected() == 1, nil
}
+32
View File
@@ -0,0 +1,32 @@
package store
import (
"context"
"testing"
)
func TestTryMarkTaskRunningIsExclusive(t *testing.T) {
s := testStore(t)
ctx := context.Background()
epSrc, _ := s.CreateEndpoint(ctx, Endpoint{RoleLabel: "src", Host: "a", Port: 993, TLSMode: "ssl"})
epDst, _ := s.CreateEndpoint(ctx, Endpoint{RoleLabel: "dst", Host: "b", Port: 993, TLSMode: "ssl"})
taskID, _ := s.CreateTask(ctx, Task{Name: "t", SrcEndpointID: epSrc, DstEndpointID: epDst})
first, err := s.TryMarkTaskRunning(ctx, taskID)
if err != nil || !first {
t.Fatalf("first acquire must succeed: ok=%v err=%v", first, err)
}
second, err := s.TryMarkTaskRunning(ctx, taskID)
if err != nil {
t.Fatalf("err: %v", err)
}
if second {
t.Fatal("second acquire must fail while running")
}
// after completion, a re-run may acquire again
_ = s.SetTaskStatus(ctx, taskID, "done")
third, _ := s.TryMarkTaskRunning(ctx, taskID)
if !third {
t.Fatal("acquire after completion must succeed")
}
}
+64
View File
@@ -0,0 +1,64 @@
package wshub
import "sync"
type Event struct {
Type string `json:"type"`
TaskID int64 `json:"task_id"`
Data any `json:"data,omitempty"`
}
type Hub struct {
mu sync.Mutex
nextID int64
subs map[int64]map[int64]chan Event // taskID -> subID -> ch
}
func New() *Hub {
return &Hub{subs: make(map[int64]map[int64]chan Event)}
}
func (h *Hub) Subscribe(taskID int64) (int64, <-chan Event) {
h.mu.Lock()
defer h.mu.Unlock()
h.nextID++
id := h.nextID
ch := make(chan Event, 64)
if h.subs[taskID] == nil {
h.subs[taskID] = make(map[int64]chan Event)
}
h.subs[taskID][id] = ch
return id, ch
}
func (h *Hub) Unsubscribe(taskID, id int64) {
h.mu.Lock()
defer h.mu.Unlock()
if m := h.subs[taskID]; m != nil {
if ch, ok := m[id]; ok {
close(ch)
delete(m, id)
}
if len(m) == 0 {
delete(h.subs, taskID)
}
}
}
// SubscriberCount returns the number of active subscribers for a task (for tests/metrics).
func (h *Hub) SubscriberCount(taskID int64) int {
h.mu.Lock()
defer h.mu.Unlock()
return len(h.subs[taskID])
}
func (h *Hub) Publish(ev Event) {
h.mu.Lock()
defer h.mu.Unlock()
for _, ch := range h.subs[ev.TaskID] {
select {
case ch <- ev:
default: // медленный подписчик — событие дропаем, не блокируем воркер
}
}
}
+31
View File
@@ -0,0 +1,31 @@
package wshub
import (
"testing"
"time"
)
func TestPublishReachesSubscriber(t *testing.T) {
h := New()
_, ch := h.Subscribe(7)
h.Publish(Event{Type: "progress", TaskID: 7, Data: map[string]int{"copied": 3}})
select {
case ev := <-ch:
if ev.Type != "progress" || ev.TaskID != 7 {
t.Fatalf("bad event %+v", ev)
}
case <-time.After(time.Second):
t.Fatal("no event received")
}
}
func TestPublishIsolatedByTask(t *testing.T) {
h := New()
_, ch := h.Subscribe(1)
h.Publish(Event{Type: "x", TaskID: 2})
select {
case <-ch:
t.Fatal("subscriber for task 1 must not get task 2 event")
case <-time.After(100 * time.Millisecond):
}
}
+5
View File
@@ -0,0 +1,5 @@
DROP TABLE IF EXISTS migrated_messages;
DROP TABLE IF EXISTS runs;
DROP TABLE IF EXISTS accounts;
DROP TABLE IF EXISTS tasks;
DROP TABLE IF EXISTS endpoints;
+53
View File
@@ -0,0 +1,53 @@
CREATE TABLE endpoints (
id BIGSERIAL PRIMARY KEY,
role_label TEXT NOT NULL,
host TEXT NOT NULL,
port INT NOT NULL,
tls_mode TEXT NOT NULL CHECK (tls_mode IN ('ssl','starttls','plain')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE tasks (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
src_endpoint_id BIGINT NOT NULL REFERENCES endpoints(id),
dst_endpoint_id BIGINT NOT NULL REFERENCES endpoints(id),
status TEXT NOT NULL DEFAULT 'draft',
folder_mapping JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE accounts (
id BIGSERIAL PRIMARY KEY,
task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
src_login TEXT NOT NULL,
src_pass_enc TEXT NOT NULL,
dst_login TEXT NOT NULL,
dst_pass_enc TEXT NOT NULL,
test_src_status TEXT NOT NULL DEFAULT 'unknown',
test_dst_status TEXT NOT NULL DEFAULT 'unknown',
copied_count BIGINT NOT NULL DEFAULT 0,
skipped_count BIGINT NOT NULL DEFAULT 0,
error_count BIGINT NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'idle'
);
CREATE TABLE runs (
id BIGSERIAL PRIMARY KEY,
task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
finished_at TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'running',
total_copied BIGINT NOT NULL DEFAULT 0,
total_skipped BIGINT NOT NULL DEFAULT 0,
total_errors BIGINT NOT NULL DEFAULT 0
);
CREATE TABLE migrated_messages (
id BIGSERIAL PRIMARY KEY,
account_id BIGINT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
folder TEXT NOT NULL,
message_key TEXT NOT NULL,
copied_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (account_id, message_key)
);
+21
View File
@@ -0,0 +1,21 @@
# Override used only by scripts/e2e.sh: adds a greenmail IMAP server that
# acts as BOTH the source and the destination (two mailboxes, one server),
# reachable from the app container via compose DNS as host "greenmail".
services:
greenmail:
image: greenmail/standalone:latest
environment:
GREENMAIL_OPTS: >-
-Dgreenmail.setup.test.all
-Dgreenmail.hostname=0.0.0.0
-Dgreenmail.auth.disabled
-Dgreenmail.verbose
ports:
# exposed to the host so the seeding script (running outside docker)
# can APPEND test messages into the source mailbox
- "3143:3143"
app:
depends_on:
greenmail:
condition: service_started
Executable
+200
View File
@@ -0,0 +1,200 @@
#!/usr/bin/env bash
# End-to-end test against the full docker-compose stack:
# postgres + app (this repo's image) + caddy + a greenmail server that plays
# BOTH the source and destination IMAP endpoint (two mailboxes on one host).
#
# Drives the real REST API: login -> create endpoints -> create task ->
# add account -> /test -> poll -> /run -> poll -> assert copied>0.
# Then runs /run a SECOND time and asserts it copies nothing new (idempotency
# via Message-ID dedup), proving the full stack end-to-end.
#
# Usage: bash scripts/e2e.sh
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
PROJECT="imapcopier-e2e"
ENV_FILE="$ROOT_DIR/.env.e2e"
COMPOSE=(docker compose -p "$PROJECT" --env-file "$ENV_FILE" -f docker-compose.yml -f scripts/docker-compose.e2e.yml)
HTTP_PORT=8089
BASE="http://localhost:${HTTP_PORT}"
AUTH_USER="e2e"
AUTH_PASS="e2e-$(openssl rand -hex 8)"
COOKIE_JAR="$(mktemp -t imapcopier-e2e-cookies.XXXXXX)"
SEED_PY="$(mktemp -t imapcopier-e2e-seed.XXXXXX.py)"
SRC_USER="src1@example.com"
DST_USER="dst1@example.com"
MAIL_PASS="anypass"
log() { echo "[e2e] $*"; }
fail() { echo "[e2e] FAIL: $*"; exit 1; }
cleanup() {
log "cleaning up (containers, volumes, temp files)"
"${COMPOSE[@]}" down -v --remove-orphans >/dev/null 2>&1 || true
rm -f "$ENV_FILE" "$COOKIE_JAR" "$SEED_PY"
}
trap cleanup EXIT
log "writing ephemeral env file $ENV_FILE"
cat > "$ENV_FILE" <<EOF
POSTGRES_PASSWORD=e2e-$(openssl rand -hex 12)
AUTH_USER=${AUTH_USER}
AUTH_PASS=${AUTH_PASS}
ENC_KEY=$(openssl rand -base64 32)
SESSION_SECRET=e2e-$(openssl rand -hex 24)
WORKER_CONCURRENCY=4
HTTP_PORT=${HTTP_PORT}
DOMAIN=
ACME_EMAIL=
EOF
log "tearing down any previous e2e stack"
"${COMPOSE[@]}" down -v --remove-orphans >/dev/null 2>&1 || true
log "building and starting stack (postgres, app, caddy, greenmail)"
"${COMPOSE[@]}" up -d --build
wait_for() {
local desc="$1" tries="$2"; shift 2
for ((i = 1; i <= tries; i++)); do
if "$@" >/dev/null 2>&1; then
log "$desc: ready"
return 0
fi
sleep 1
done
fail "$desc: timed out after ${tries}s"
}
log "waiting for app healthz"
wait_for "app /healthz" 60 curl -fsS "$BASE/healthz"
log "waiting for greenmail IMAP port"
wait_for "greenmail:3143" 30 bash -c "exec 3<>/dev/tcp/127.0.0.1/3143"
log "seeding two messages into ${SRC_USER} INBOX"
cat > "$SEED_PY" <<'PYEOF'
import imaplib, sys, time
host, port, user, password, count = sys.argv[1], int(sys.argv[2]), sys.argv[3], sys.argv[4], int(sys.argv[5])
m = imaplib.IMAP4(host, port)
m.login(user, password)
m.select("INBOX")
for i in range(count):
msg = (
f"Message-ID: <e2e-{i}-{int(time.time())}@example.com>\r\n"
f"From: sender@example.com\r\n"
f"To: {user}\r\n"
f"Subject: e2e test message {i}\r\n"
f"\r\n"
f"body {i}\r\n"
).encode()
typ, data = m.append("INBOX", None, imaplib.Time2Internaldate(time.time()), msg)
if typ != "OK":
print(f"append failed: {typ} {data}", file=sys.stderr)
sys.exit(1)
m.logout()
print("seeded", count, "messages")
PYEOF
python3 "$SEED_PY" 127.0.0.1 3143 "$SRC_USER" "$MAIL_PASS" 2
api() {
local method="$1" path="$2" data="${3:-}"
if [[ -n "$data" ]]; then
curl -fsS -b "$COOKIE_JAR" -c "$COOKIE_JAR" -X "$method" "$BASE$path" \
-H 'Content-Type: application/json' -d "$data"
else
curl -fsS -b "$COOKIE_JAR" -c "$COOKIE_JAR" -X "$method" "$BASE$path"
fi
}
log "logging in as ${AUTH_USER}"
curl -fsS -c "$COOKIE_JAR" -X POST "$BASE/api/login" \
-H 'Content-Type: application/json' \
-d "{\"user\":\"${AUTH_USER}\",\"pass\":\"${AUTH_PASS}\"}" >/dev/null
log "creating src/dst endpoints (both point at greenmail:3143)"
SRC_EP_ID=$(api POST /api/endpoints '{"role_label":"src","host":"greenmail","port":3143,"tls_mode":"plain"}' | jq -r .id)
DST_EP_ID=$(api POST /api/endpoints '{"role_label":"dst","host":"greenmail","port":3143,"tls_mode":"plain"}' | jq -r .id)
[[ "$SRC_EP_ID" =~ ^[0-9]+$ ]] || fail "bad src endpoint id: $SRC_EP_ID"
[[ "$DST_EP_ID" =~ ^[0-9]+$ ]] || fail "bad dst endpoint id: $DST_EP_ID"
log "src_endpoint_id=$SRC_EP_ID dst_endpoint_id=$DST_EP_ID"
log "creating task"
TASK_ID=$(api POST /api/tasks "{\"name\":\"e2e\",\"src_endpoint_id\":${SRC_EP_ID},\"dst_endpoint_id\":${DST_EP_ID},\"folder_mapping\":{}}" | jq -r .id)
[[ "$TASK_ID" =~ ^[0-9]+$ ]] || fail "bad task id: $TASK_ID"
log "task_id=$TASK_ID"
log "adding account (src -> dst)"
ACCOUNT_ID=$(api POST "/api/tasks/${TASK_ID}/accounts" \
"{\"src_login\":\"${SRC_USER}\",\"src_pass\":\"${MAIL_PASS}\",\"dst_login\":\"${DST_USER}\",\"dst_pass\":\"${MAIL_PASS}\"}" | jq -r .id)
[[ "$ACCOUNT_ID" =~ ^[0-9]+$ ]] || fail "bad account id: $ACCOUNT_ID"
log "account_id=$ACCOUNT_ID"
log "POST /test"
api POST "/api/tasks/${TASK_ID}/test" >/dev/null
wait_test_ok() {
for ((i = 1; i <= 30; i++)); do
local res src dst
res=$(api GET "/api/tasks/${TASK_ID}")
src=$(echo "$res" | jq -r '.accounts[0].test_src_status')
dst=$(echo "$res" | jq -r '.accounts[0].test_dst_status')
if [[ "$src" == "ok" && "$dst" == "ok" ]]; then
log "connection tests: src=$src dst=$dst"
return 0
fi
if [[ "$src" == "fail" || "$dst" == "fail" ]]; then
fail "connection test failed: src=$src dst=$dst"
fi
sleep 1
done
fail "connection tests did not complete in time"
}
wait_test_ok
wait_run_done() {
for ((i = 1; i <= 60; i++)); do
local status
status=$(api GET "/api/tasks/${TASK_ID}" | jq -r '.task.status')
if [[ "$status" == "done" ]]; then
return 0
fi
sleep 1
done
fail "run did not finish in time (last status=$status)"
}
log "POST /run (first run)"
api POST "/api/tasks/${TASK_ID}/run" >/dev/null
wait_run_done
RES1=$(api GET "/api/tasks/${TASK_ID}")
RUN1_COPIED=$(echo "$RES1" | jq -r '.accounts[0].copied')
RUN1_SKIPPED=$(echo "$RES1" | jq -r '.accounts[0].skipped')
RUN1_ERRORS=$(echo "$RES1" | jq -r '.accounts[0].errors')
log "run 1: copied=$RUN1_COPIED skipped=$RUN1_SKIPPED errors=$RUN1_ERRORS"
[[ "$RUN1_ERRORS" == "0" ]] || fail "run 1 had errors"
[[ "$RUN1_COPIED" -gt 0 ]] || fail "run 1 copied nothing (expected >0)"
log "POST /run (second run, expect idempotency)"
api POST "/api/tasks/${TASK_ID}/run" >/dev/null
wait_run_done
RES2=$(api GET "/api/tasks/${TASK_ID}")
RUN2_COPIED_TOTAL=$(echo "$RES2" | jq -r '.accounts[0].copied')
RUN2_SKIPPED_TOTAL=$(echo "$RES2" | jq -r '.accounts[0].skipped')
RUN2_ERRORS=$(echo "$RES2" | jq -r '.accounts[0].errors')
RUN2_COPIED_DELTA=$((RUN2_COPIED_TOTAL - RUN1_COPIED))
RUN2_SKIPPED_DELTA=$((RUN2_SKIPPED_TOTAL - RUN1_SKIPPED))
log "run 2: copied_delta=$RUN2_COPIED_DELTA skipped_delta=$RUN2_SKIPPED_DELTA errors=$RUN2_ERRORS"
[[ "$RUN2_ERRORS" == "0" ]] || fail "run 2 had errors"
[[ "$RUN2_COPIED_DELTA" -eq 0 ]] || fail "run 2 copied $RUN2_COPIED_DELTA new messages (expected 0, not idempotent)"
[[ "$RUN2_SKIPPED_DELTA" -gt 0 ]] || fail "run 2 skipped delta is $RUN2_SKIPPED_DELTA (expected >0)"
log "PASS: run1 copied=$RUN1_COPIED skipped=$RUN1_SKIPPED; run2 copied=$RUN2_COPIED_DELTA skipped=$RUN2_SKIPPED_DELTA (idempotent)"
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
+32
View File
@@ -0,0 +1,32 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<title>imap/copier — operator console</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1439
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"@fontsource/big-shoulders-display": "^5.2.5",
"@fontsource/jetbrains-mono": "^5.2.8",
"react": "^19.2.7",
"react-dom": "^19.2.7"
},
"devDependencies": {
"@types/node": "^24.13.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"oxlint": "^1.71.0",
"typescript": "~6.0.2",
"vite": "^8.1.1"
}
}
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
<rect width="48" height="48" rx="6" fill="#0a0f0d"/>
<path d="M10 15 L20 24 L10 33" fill="none" stroke="#ffb238" stroke-width="4" stroke-linecap="square" stroke-linejoin="miter"/>
<rect x="24" y="30" width="14" height="4" fill="#ffb238"/>
</svg>

After

Width:  |  Height:  |  Size: 336 B

+88
View File
@@ -0,0 +1,88 @@
import { useEffect, useState } from 'react'
import './app.css'
import { logout } from './api'
import { Login } from './pages/Login'
import { Endpoints } from './pages/Endpoints'
import { Tasks } from './pages/Tasks'
import { TaskDetail } from './pages/TaskDetail'
type Route =
| { name: 'login' }
| { name: 'tasks' }
| { name: 'endpoints' }
| { name: 'task'; id: number }
| { name: 'notfound' }
function parseRoute(hash: string): Route {
const path = hash.replace(/^#/, '') || '/'
if (path === '/login') return { name: 'login' }
if (path === '/') return { name: 'tasks' }
if (path === '/endpoints') return { name: 'endpoints' }
const m = path.match(/^\/tasks\/(\d+)$/)
if (m) return { name: 'task', id: Number(m[1]) }
return { name: 'notfound' }
}
function useHashRoute(): Route {
const [hash, setHash] = useState(location.hash)
useEffect(() => {
const onChange = () => setHash(location.hash)
window.addEventListener('hashchange', onChange)
return () => window.removeEventListener('hashchange', onChange)
}, [])
return parseRoute(hash)
}
function App() {
const route = useHashRoute()
if (route.name === 'login') {
return <Login onSuccess={() => (location.hash = '#/')} />
}
function handleLogout() {
logout()
.catch(() => {})
.finally(() => (location.hash = '#/login'))
}
return (
<div className="shell">
<header className="topbar">
<div className="brand">
<span className="bracket">[</span>IMAP<span className="dim">/COPIER</span>
<span className="bracket">]</span>
</div>
<nav className="topnav">
<a href="#/" className={route.name === 'tasks' || route.name === 'task' ? 'active' : ''}>
Tasks
</a>
<a href="#/endpoints" className={route.name === 'endpoints' ? 'active' : ''}>
Endpoints
</a>
</nav>
<div className="session-indicator">
<span className="pulse-dot" /> session active
</div>
<button className="btn btn-ghost" onClick={handleLogout}>
Logout
</button>
</header>
<main className="main">
{route.name === 'tasks' && <Tasks />}
{route.name === 'endpoints' && <Endpoints />}
{route.name === 'task' && <TaskDetail id={route.id} />}
{route.name === 'notfound' && (
<div className="panel">
<p>Unknown route.</p>
<a className="crumb" href="#/">
back to tasks
</a>
</div>
)}
</main>
</div>
)
}
export default App
+98
View File
@@ -0,0 +1,98 @@
// REST client for the imap-copier control API.
// All requests carry the session cookie; a 401 anywhere bounces to #/login.
export type TLSMode = 'ssl' | 'starttls' | 'plain'
export interface Endpoint {
id: number
role_label: string
host: string
port: number
tls_mode: TLSMode
}
export interface Task {
id: number
name: string
src_endpoint_id: number
dst_endpoint_id: number
status: string
folder_mapping?: Record<string, string>
}
export type TestStatus = 'pending' | 'ok' | 'fail' | string
export interface Account {
id: number
src_login: string
dst_login: string
test_src_status: TestStatus
test_dst_status: TestStatus
status: string
copied: number
skipped: number
errors: number
}
export interface TaskDetail {
task: Task
accounts: Account[]
}
export class ApiError extends Error {}
export async function api<T = unknown>(path: string, opts: RequestInit = {}): Promise<T> {
const res = await fetch(path, { credentials: 'include', ...opts })
if (res.status === 401) {
location.hash = '#/login'
throw new ApiError('unauthorized')
}
if (!res.ok) {
const body = await res.text()
throw new ApiError(body || res.statusText)
}
const ct = res.headers.get('content-type') || ''
if (ct.includes('application/json')) return res.json() as Promise<T>
return res.text() as unknown as T
}
const jsonBody = (body: unknown): RequestInit => ({
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
})
export const login = (user: string, pass: string) => api('/api/login', jsonBody({ user, pass }))
export const logout = () => api('/api/logout', { method: 'POST' })
export const listEndpoints = () => api<Endpoint[]>('/api/endpoints')
export const createEndpoint = (body: { role_label: string; host: string; port: number; tls_mode: TLSMode }) =>
api<{ id: number }>('/api/endpoints', jsonBody(body))
export const listTasks = () => api<Task[]>('/api/tasks')
export const getTask = (id: number) => api<TaskDetail>(`/api/tasks/${id}`)
export const createTask = (body: {
name: string
src_endpoint_id: number
dst_endpoint_id: number
folder_mapping?: Record<string, string>
}) => api<{ id: number }>('/api/tasks', jsonBody(body))
export const createAccount = (
id: number,
body: { src_login: string; src_pass: string; dst_login: string; dst_pass: string },
) => api<{ id: number }>(`/api/tasks/${id}/accounts`, jsonBody(body))
export const testAccounts = (id: number) => api(`/api/tasks/${id}/test`, { method: 'POST' })
export const runTask = (id: number) => api(`/api/tasks/${id}/run`, { method: 'POST' })
export const importCSV = (id: number, file: File) => {
const fd = new FormData()
fd.append('file', file)
return api<{ imported: number }>(`/api/tasks/${id}/import`, { method: 'POST', body: fd })
}
+568
View File
@@ -0,0 +1,568 @@
/* ---------- layout shell ---------- */
.shell {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.topbar {
display: flex;
align-items: center;
gap: 28px;
padding: 0 24px;
height: 56px;
background: var(--bg-panel-raised);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 10;
}
.brand {
display: flex;
align-items: baseline;
gap: 2px;
font-family: var(--font-display);
font-weight: 800;
font-size: 22px;
letter-spacing: 0.5px;
text-transform: uppercase;
white-space: nowrap;
}
.brand .bracket {
color: var(--accent);
}
.brand .dim {
color: var(--fg-dim);
font-weight: 600;
}
.topnav {
display: flex;
gap: 4px;
flex: 1;
}
.topnav a {
display: inline-block;
padding: 8px 14px;
text-decoration: none;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--fg-dim);
border: 1px solid transparent;
border-radius: 2px;
transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease;
}
.topnav a:hover {
color: var(--fg);
border-color: var(--border);
}
.topnav a.active {
color: var(--accent-strong);
border-color: var(--accent-dim);
background: rgba(255, 178, 56, 0.06);
}
.session-indicator {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
color: var(--fg-dim);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.pulse-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--ok);
box-shadow: 0 0 6px 1px var(--ok);
animation: pulse 2.4s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.35; }
}
.main {
flex: 1;
width: 100%;
max-width: 1180px;
margin: 0 auto;
padding: 32px 24px 64px;
}
.page-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px;
flex-wrap: wrap;
}
.page-title {
font-family: var(--font-display);
font-weight: 800;
font-size: 34px;
letter-spacing: 0.3px;
text-transform: uppercase;
margin: 0;
display: flex;
align-items: center;
gap: 12px;
}
.page-title .idx {
font-family: var(--font-mono);
font-size: 13px;
font-weight: 500;
color: var(--fg-faint);
letter-spacing: 0.1em;
}
.crumb {
font-size: 12px;
color: var(--fg-dim);
letter-spacing: 0.08em;
text-transform: uppercase;
text-decoration: none;
border-bottom: 1px dashed var(--border-bright);
}
.crumb:hover {
color: var(--accent-strong);
}
/* ---------- panels ---------- */
.panel {
position: relative;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 3px;
padding: 20px;
margin-bottom: 20px;
}
.panel-label {
position: absolute;
top: -9px;
left: 14px;
background: var(--bg);
padding: 0 8px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent);
}
.panel-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
gap: 20px;
}
/* ---------- forms ---------- */
.field {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 14px;
}
.field label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--fg-dim);
}
.field input,
.field select {
background: var(--bg-inset);
border: 1px solid var(--border);
color: var(--fg);
padding: 9px 11px;
font-size: 13px;
border-radius: 2px;
outline: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.field input:focus,
.field select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(255, 178, 56, 0.12);
}
.field-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.hint {
font-size: 11px;
color: var(--fg-faint);
}
/* ---------- buttons ---------- */
.btn {
appearance: none;
border: 1px solid var(--border-bright);
background: transparent;
color: var(--fg);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
padding: 10px 18px;
border-radius: 2px;
transition: all 0.15s ease;
}
.btn:hover:not(:disabled) {
border-color: var(--fg-dim);
color: var(--accent-strong);
}
.btn-primary {
background: var(--accent);
border-color: var(--accent);
color: #1a1200;
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-strong);
border-color: var(--accent-strong);
color: #1a1200;
box-shadow: 0 0 16px -2px rgba(255, 178, 56, 0.6);
}
.btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.btn-row {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.btn-ghost {
border-color: transparent;
color: var(--fg-dim);
padding: 8px 10px;
}
.btn-ghost:hover:not(:disabled) {
color: var(--fail);
}
/* ---------- status badges ---------- */
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 3px 9px 3px 7px;
border-radius: 2px;
border: 1px solid;
white-space: nowrap;
}
.badge .dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
.badge-ok {
color: var(--ok);
border-color: var(--ok-dim);
background: rgba(82, 230, 160, 0.06);
}
.badge-ok .dot { background: var(--ok); box-shadow: 0 0 5px var(--ok); }
.badge-fail {
color: var(--fail);
border-color: var(--fail-dim);
background: rgba(255, 93, 93, 0.06);
}
.badge-fail .dot { background: var(--fail); box-shadow: 0 0 5px var(--fail); }
.badge-pending {
color: var(--pending);
border-color: #4a4423;
background: rgba(240, 196, 25, 0.06);
}
.badge-pending .dot { background: var(--pending); }
.badge-info {
color: var(--info);
border-color: #234456;
background: rgba(87, 194, 255, 0.06);
}
.badge-info .dot { background: var(--info); animation: pulse 1.4s ease-in-out infinite; }
/* ---------- tables ---------- */
.tbl-wrap {
overflow-x: auto;
border: 1px solid var(--border);
border-radius: 3px;
}
table.tbl {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
table.tbl thead th {
text-align: left;
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--fg-dim);
background: var(--bg-panel-raised);
padding: 10px 14px;
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
table.tbl tbody td {
padding: 10px 14px;
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
table.tbl tbody tr:last-child td {
border-bottom: none;
}
table.tbl tbody tr:hover {
background: rgba(255, 255, 255, 0.015);
}
table.tbl a.rowlink {
color: var(--fg);
text-decoration: none;
}
table.tbl a.rowlink:hover {
color: var(--accent-strong);
}
.num-cell {
font-variant-numeric: tabular-nums;
text-align: right;
}
.empty-row td {
text-align: center;
color: var(--fg-faint);
padding: 28px 14px;
font-style: normal;
}
/* ---------- login ---------- */
.login-wrap {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
position: relative;
}
.login-card {
width: 100%;
max-width: 380px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 3px;
padding: 32px 28px;
position: relative;
}
.login-card::before {
content: '';
position: absolute;
inset: -1px;
border-radius: 3px;
padding: 1px;
background: linear-gradient(135deg, var(--accent-dim), transparent 40%);
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
.login-brand {
font-family: var(--font-display);
font-weight: 800;
font-size: 30px;
letter-spacing: 0.5px;
text-transform: uppercase;
margin: 0 0 4px;
}
.login-sub {
color: var(--fg-dim);
font-size: 12px;
letter-spacing: 0.06em;
margin-bottom: 24px;
}
.login-error {
color: var(--fail);
font-size: 12px;
margin-top: 10px;
min-height: 16px;
}
/* ---------- task detail ---------- */
.stat-row {
display: flex;
gap: 28px;
flex-wrap: wrap;
margin-bottom: 4px;
}
.stat {
display: flex;
flex-direction: column;
gap: 2px;
}
.stat .val {
font-size: 24px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.stat .lbl {
font-size: 10.5px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--fg-dim);
}
.stat.ok .val { color: var(--ok); }
.stat.fail .val { color: var(--fail); }
.stat.info .val { color: var(--info); }
.upload-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.file-btn {
position: relative;
overflow: hidden;
}
.file-btn input[type='file'] {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
.log-pane {
background: var(--bg-inset);
border: 1px solid var(--border);
border-radius: 3px;
padding: 12px 14px;
height: 260px;
overflow-y: auto;
font-size: 12px;
display: flex;
flex-direction: column-reverse;
}
.log-line {
padding: 3px 0;
border-bottom: 1px dotted rgba(255, 255, 255, 0.04);
display: flex;
gap: 10px;
color: var(--fg-dim);
}
.log-line .tag {
flex-shrink: 0;
color: var(--accent);
font-weight: 700;
}
.log-line .payload {
color: var(--fg);
word-break: break-all;
}
.log-empty {
color: var(--fg-faint);
text-align: center;
margin: auto;
}
.divider-label {
display: flex;
align-items: center;
gap: 10px;
margin: 22px 0 14px;
color: var(--fg-faint);
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.divider-label::before,
.divider-label::after {
content: '';
flex: 1;
height: 1px;
background: var(--border);
}
.error-banner {
border: 1px solid var(--fail-dim);
background: rgba(255, 93, 93, 0.08);
color: var(--fail);
padding: 10px 14px;
border-radius: 2px;
font-size: 12px;
margin-bottom: 16px;
}
.muted-note {
color: var(--fg-faint);
font-size: 12px;
}
+13
View File
@@ -0,0 +1,13 @@
export function StatusBadge({ status }: { status: string }) {
const s = (status || 'pending').toLowerCase()
let cls = 'badge-pending'
if (s === 'ok' || s === 'done' || s === 'success') cls = 'badge-ok'
else if (s === 'fail' || s === 'failed' || s === 'error') cls = 'badge-fail'
else if (s === 'running' || s === 'testing' || s === 'in_progress') cls = 'badge-info'
return (
<span className={`badge ${cls}`}>
<span className="dot" />
{s}
</span>
)
}
+98
View File
@@ -0,0 +1,98 @@
@import '@fontsource/jetbrains-mono/400.css';
@import '@fontsource/jetbrains-mono/500.css';
@import '@fontsource/jetbrains-mono/700.css';
@import '@fontsource/big-shoulders-display/600.css';
@import '@fontsource/big-shoulders-display/800.css';
:root {
--bg: #0a0d0b;
--bg-panel: #0f1512;
--bg-panel-raised: #141b17;
--bg-inset: #070a08;
--border: #23342b;
--border-bright: #3a5443;
--fg: #dbe8de;
--fg-dim: #6f8478;
--fg-faint: #4a5c50;
--accent: #ffb238;
--accent-strong: #ffd27a;
--accent-dim: #7a5a26;
--ok: #52e6a0;
--ok-dim: #234a37;
--fail: #ff5d5d;
--fail-dim: #4a2323;
--pending: #f0c419;
--info: #57c2ff;
--font-display: 'Big Shoulders Display', 'Arial Narrow', sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
color-scheme: dark;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
}
body {
min-height: 100vh;
background:
radial-gradient(ellipse 80% 60% at 50% -10%, rgba(255, 178, 56, 0.06), transparent),
repeating-linear-gradient(
to bottom,
rgba(255, 255, 255, 0.012) 0px,
rgba(255, 255, 255, 0.012) 1px,
transparent 1px,
transparent 3px
),
var(--bg);
color: var(--fg);
font-family: var(--font-mono);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
#root {
min-height: 100vh;
}
a {
color: inherit;
}
button {
font-family: var(--font-mono);
cursor: pointer;
}
input,
select {
font-family: var(--font-mono);
}
::selection {
background: var(--accent-dim);
color: var(--accent-strong);
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: var(--bg-inset);
}
::-webkit-scrollbar-thumb {
background: var(--border-bright);
}
.mono-num {
font-variant-numeric: tabular-nums;
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+145
View File
@@ -0,0 +1,145 @@
import { useEffect, useState, type FormEvent } from 'react'
import { createEndpoint, listEndpoints, type Endpoint, type TLSMode } from '../api'
const emptyForm = { role_label: '', host: '', port: '993', tls_mode: 'ssl' as TLSMode }
export function Endpoints() {
const [endpoints, setEndpoints] = useState<Endpoint[] | null>(null)
const [form, setForm] = useState(emptyForm)
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
function reload() {
listEndpoints()
.then(setEndpoints)
.catch((e) => setError(String(e.message || e)))
}
useEffect(reload, [])
async function submit(e: FormEvent) {
e.preventDefault()
setBusy(true)
setError(null)
try {
await createEndpoint({
role_label: form.role_label,
host: form.host,
port: Number(form.port),
tls_mode: form.tls_mode,
})
setForm(emptyForm)
reload()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create endpoint')
} finally {
setBusy(false)
}
}
return (
<>
<div className="page-head">
<h1 className="page-title">
Endpoints <span className="idx">/// mailbox servers</span>
</h1>
</div>
<div className="panel-grid">
<div className="panel">
<span className="panel-label">Register endpoint</span>
<form onSubmit={submit}>
<div className="field">
<label htmlFor="role_label">Role label</label>
<input
id="role_label"
placeholder="e.g. src-legacy, dst-office365"
value={form.role_label}
onChange={(e) => setForm({ ...form, role_label: e.target.value })}
required
/>
</div>
<div className="field">
<label htmlFor="host">Host</label>
<input
id="host"
placeholder="imap.example.com"
value={form.host}
onChange={(e) => setForm({ ...form, host: e.target.value })}
required
/>
</div>
<div className="field-row">
<div className="field">
<label htmlFor="port">Port</label>
<input
id="port"
type="number"
value={form.port}
onChange={(e) => setForm({ ...form, port: e.target.value })}
required
/>
</div>
<div className="field">
<label htmlFor="tls_mode">TLS mode</label>
<select
id="tls_mode"
value={form.tls_mode}
onChange={(e) => setForm({ ...form, tls_mode: e.target.value as TLSMode })}
>
<option value="ssl">ssl</option>
<option value="starttls">starttls</option>
<option value="plain">plain</option>
</select>
</div>
</div>
{error && <div className="error-banner">{error}</div>}
<div className="btn-row">
<button className="btn btn-primary" disabled={busy}>
{busy ? 'Saving…' : 'Add endpoint'}
</button>
</div>
</form>
</div>
<div className="panel">
<span className="panel-label">Registered ({endpoints?.length ?? 0})</span>
<div className="tbl-wrap">
<table className="tbl">
<thead>
<tr>
<th>ID</th>
<th>Role</th>
<th>Host</th>
<th>Port</th>
<th>TLS</th>
</tr>
</thead>
<tbody>
{endpoints === null ? (
<tr className="empty-row">
<td colSpan={5}>loading</td>
</tr>
) : endpoints.length === 0 ? (
<tr className="empty-row">
<td colSpan={5}>no endpoints registered yet</td>
</tr>
) : (
endpoints.map((ep) => (
<tr key={ep.id}>
<td className="num-cell">{ep.id}</td>
<td>{ep.role_label}</td>
<td>{ep.host}</td>
<td className="num-cell">{ep.port}</td>
<td>{ep.tls_mode}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
</>
)
}
+61
View File
@@ -0,0 +1,61 @@
import { useState, type FormEvent } from 'react'
import { login } from '../api'
export function Login({ onSuccess }: { onSuccess: () => void }) {
const [user, setUser] = useState('')
const [pass, setPass] = useState('')
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
async function submit(e: FormEvent) {
e.preventDefault()
if (!user || !pass) return
setBusy(true)
setError(null)
try {
await login(user, pass)
onSuccess()
} catch {
setError('Access denied — check operator id and passphrase.')
} finally {
setBusy(false)
}
}
return (
<div className="login-wrap">
<form className="login-card" onSubmit={submit}>
<h1 className="login-brand">
<span style={{ color: 'var(--accent)' }}>[</span>IMAP/COPIER<span style={{ color: 'var(--accent)' }}>]</span>
</h1>
<p className="login-sub">OPERATOR CONSOLE AUTHENTICATE TO CONTINUE</p>
<div className="field">
<label htmlFor="user">Operator ID</label>
<input
id="user"
autoFocus
value={user}
onChange={(e) => setUser(e.target.value)}
autoComplete="username"
spellCheck={false}
/>
</div>
<div className="field">
<label htmlFor="pass">Passphrase</label>
<input
id="pass"
type="password"
value={pass}
onChange={(e) => setPass(e.target.value)}
autoComplete="current-password"
/>
</div>
<button className="btn btn-primary" style={{ width: '100%' }} disabled={busy || !user || !pass}>
{busy ? 'Authenticating…' : 'Sign in'}
</button>
<div className="login-error">{error}</div>
</form>
</div>
)
}
+289
View File
@@ -0,0 +1,289 @@
import { useEffect, useRef, useState, type ChangeEvent, type FormEvent } from 'react'
import { createAccount, getTask, importCSV, runTask, testAccounts, type TaskDetail as TaskDetailData } from '../api'
import { connectTaskWS, type TaskEvent } from '../ws'
import { StatusBadge } from '../components/StatusBadge'
const emptyAccount = { src_login: '', src_pass: '', dst_login: '', dst_pass: '' }
export function TaskDetail({ id }: { id: number }) {
const [data, setData] = useState<TaskDetailData | null>(null)
const [notFound, setNotFound] = useState(false)
const [log, setLog] = useState<{ type: string; text: string }[]>([])
const [form, setForm] = useState(emptyAccount)
const [busy, setBusy] = useState<'test' | 'run' | 'add' | 'import' | null>(null)
const [error, setError] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
function reload() {
getTask(id)
.then((d) => {
setData(d)
setNotFound(false)
})
.catch(() => setNotFound(true))
}
useEffect(reload, [id])
useEffect(
() =>
connectTaskWS(id, (ev: TaskEvent) => {
setLog((l) => [{ type: ev.type, text: JSON.stringify(ev.data) }, ...l].slice(0, 300))
if (['account_started', 'account_test', 'account_done', 'progress', 'run_started', 'run_done', 'error'].includes(ev.type)) {
reload()
}
}),
[id],
)
async function submitAccount(e: FormEvent) {
e.preventDefault()
setBusy('add')
setError(null)
try {
await createAccount(id, form)
setForm(emptyAccount)
reload()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to add account')
} finally {
setBusy(null)
}
}
async function onFileChosen(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
setBusy('import')
setError(null)
try {
await importCSV(id, file)
reload()
} catch (err) {
setError(err instanceof Error ? err.message : 'CSV import failed')
} finally {
setBusy(null)
if (fileInputRef.current) fileInputRef.current.value = ''
}
}
async function onTest() {
setBusy('test')
setError(null)
try {
await testAccounts(id)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to start connection tests')
} finally {
setBusy(null)
}
}
async function onRun() {
setBusy('run')
setError(null)
try {
await runTask(id)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to start run')
} finally {
setBusy(null)
}
}
if (notFound) {
return (
<div className="panel">
<p>Task #{id} not found.</p>
<a className="crumb" href="#/">
back to tasks
</a>
</div>
)
}
if (!data) {
return <div className="muted-note">loading task #{id}</div>
}
const { task, accounts } = data
const allTested = accounts.length > 0 && accounts.every((a) => a.test_src_status === 'ok' && a.test_dst_status === 'ok')
const totals = accounts.reduce(
(acc, a) => ({ copied: acc.copied + a.copied, skipped: acc.skipped + a.skipped, errors: acc.errors + a.errors }),
{ copied: 0, skipped: 0, errors: 0 },
)
return (
<>
<div className="page-head">
<div>
<a className="crumb" href="#/">
all tasks
</a>
<h1 className="page-title" style={{ marginTop: 6 }}>
{task.name} <span className="idx">/// task #{task.id}</span>
</h1>
</div>
<StatusBadge status={task.status} />
</div>
<div className="panel">
<span className="panel-label">Run control</span>
<div className="stat-row">
<div className="stat ok">
<span className="val mono-num">{totals.copied}</span>
<span className="lbl">copied</span>
</div>
<div className="stat info">
<span className="val mono-num">{totals.skipped}</span>
<span className="lbl">skipped</span>
</div>
<div className="stat fail">
<span className="val mono-num">{totals.errors}</span>
<span className="lbl">errors</span>
</div>
<div className="stat">
<span className="val mono-num">{accounts.length}</span>
<span className="lbl">accounts</span>
</div>
</div>
{error && <div className="error-banner">{error}</div>}
<div className="btn-row" style={{ marginTop: 16 }}>
<button className="btn" onClick={onTest} disabled={busy !== null || accounts.length === 0}>
{busy === 'test' ? 'Testing…' : 'Test connections'}
</button>
<button className="btn btn-primary" onClick={onRun} disabled={busy !== null || !allTested || task.status === 'running'}>
{busy === 'run' ? 'Starting…' : 'Run migration'}
</button>
{!allTested && accounts.length > 0 && <span className="hint">run unlocks once every account tests OK on both sides</span>}
</div>
</div>
<div className="panel-grid">
<div className="panel">
<span className="panel-label">Add account</span>
<form onSubmit={submitAccount}>
<div className="field-row">
<div className="field">
<label htmlFor="src_login">Source login</label>
<input
id="src_login"
value={form.src_login}
onChange={(e) => setForm({ ...form, src_login: e.target.value })}
required
/>
</div>
<div className="field">
<label htmlFor="src_pass">Source password</label>
<input
id="src_pass"
type="password"
value={form.src_pass}
onChange={(e) => setForm({ ...form, src_pass: e.target.value })}
required
/>
</div>
</div>
<div className="field-row">
<div className="field">
<label htmlFor="dst_login">Destination login</label>
<input
id="dst_login"
value={form.dst_login}
onChange={(e) => setForm({ ...form, dst_login: e.target.value })}
required
/>
</div>
<div className="field">
<label htmlFor="dst_pass">Destination password</label>
<input
id="dst_pass"
type="password"
value={form.dst_pass}
onChange={(e) => setForm({ ...form, dst_pass: e.target.value })}
required
/>
</div>
</div>
<div className="btn-row">
<button className="btn btn-primary" disabled={busy !== null}>
{busy === 'add' ? 'Adding…' : 'Add account'}
</button>
</div>
</form>
<div className="divider-label">or bulk import</div>
<div className="upload-row">
<button className="btn file-btn" disabled={busy !== null}>
{busy === 'import' ? 'Importing…' : 'Upload CSV'}
<input ref={fileInputRef} type="file" accept=".csv,text/csv" onChange={onFileChosen} disabled={busy !== null} />
</button>
<span className="hint">columns: src_login, src_pass, dst_login, dst_pass</span>
</div>
</div>
<div className="panel">
<span className="panel-label">Event log</span>
<div className="log-pane">
{log.length === 0 ? (
<div className="log-empty">awaiting events over websocket</div>
) : (
log.map((l, i) => (
<div className="log-line" key={i}>
<span className="tag">{l.type}</span>
<span className="payload">{l.text}</span>
</div>
))
)}
</div>
</div>
</div>
<div className="panel">
<span className="panel-label">Accounts ({accounts.length})</span>
<div className="tbl-wrap">
<table className="tbl">
<thead>
<tr>
<th>Source</th>
<th>Destination</th>
<th>Src test</th>
<th>Dst test</th>
<th>Status</th>
<th>Copied</th>
<th>Skipped</th>
<th>Errors</th>
</tr>
</thead>
<tbody>
{accounts.length === 0 ? (
<tr className="empty-row">
<td colSpan={8}>no accounts yet add one or import a CSV above</td>
</tr>
) : (
accounts.map((a) => (
<tr key={a.id}>
<td>{a.src_login}</td>
<td>{a.dst_login}</td>
<td>
<StatusBadge status={a.test_src_status} />
</td>
<td>
<StatusBadge status={a.test_dst_status} />
</td>
<td>
<StatusBadge status={a.status} />
</td>
<td className="num-cell">{a.copied}</td>
<td className="num-cell">{a.skipped}</td>
<td className="num-cell">{a.errors}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</>
)
}
+156
View File
@@ -0,0 +1,156 @@
import { useEffect, useState, type FormEvent } from 'react'
import { createTask, listEndpoints, listTasks, type Endpoint, type Task } from '../api'
import { StatusBadge } from '../components/StatusBadge'
export function Tasks() {
const [tasks, setTasks] = useState<Task[] | null>(null)
const [endpoints, setEndpoints] = useState<Endpoint[]>([])
const [name, setName] = useState('')
const [srcId, setSrcId] = useState('')
const [dstId, setDstId] = useState('')
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
function reload() {
listTasks()
.then(setTasks)
.catch((e: unknown) => setError(e instanceof Error ? e.message : 'Failed to load tasks'))
}
useEffect(() => {
reload()
listEndpoints().then(setEndpoints).catch(() => {})
}, [])
async function submit(e: FormEvent) {
e.preventDefault()
setBusy(true)
setError(null)
try {
const res = await createTask({
name,
src_endpoint_id: Number(srcId),
dst_endpoint_id: Number(dstId),
})
setName('')
setSrcId('')
setDstId('')
reload()
location.hash = `#/tasks/${res.id}`
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create task')
} finally {
setBusy(false)
}
}
const canSubmit = name.trim() !== '' && srcId !== '' && dstId !== '' && srcId !== dstId
return (
<>
<div className="page-head">
<h1 className="page-title">
Migration tasks <span className="idx">/// mailbox copy jobs</span>
</h1>
</div>
<div className="panel">
<span className="panel-label">New task</span>
{endpoints.length < 2 ? (
<p className="muted-note">
Register at least two endpoints (source &amp; destination) on the{' '}
<a className="crumb" href="#/endpoints">
Endpoints
</a>{' '}
screen before creating a task.
</p>
) : (
<form onSubmit={submit}>
<div className="field">
<label htmlFor="name">Task name</label>
<input id="name" value={name} onChange={(e) => setName(e.target.value)} placeholder="q3-office365-migration" required />
</div>
<div className="field-row">
<div className="field">
<label htmlFor="src">Source endpoint</label>
<select id="src" value={srcId} onChange={(e) => setSrcId(e.target.value)} required>
<option value="" disabled>
select
</option>
{endpoints.map((ep) => (
<option key={ep.id} value={ep.id}>
{ep.role_label} {ep.host}:{ep.port}
</option>
))}
</select>
</div>
<div className="field">
<label htmlFor="dst">Destination endpoint</label>
<select id="dst" value={dstId} onChange={(e) => setDstId(e.target.value)} required>
<option value="" disabled>
select
</option>
{endpoints.map((ep) => (
<option key={ep.id} value={ep.id}>
{ep.role_label} {ep.host}:{ep.port}
</option>
))}
</select>
</div>
</div>
{error && <div className="error-banner">{error}</div>}
<div className="btn-row">
<button className="btn btn-primary" disabled={busy || !canSubmit}>
{busy ? 'Creating…' : 'Create task'}
</button>
</div>
</form>
)}
</div>
<div className="panel">
<span className="panel-label">All tasks ({tasks?.length ?? 0})</span>
<div className="tbl-wrap">
<table className="tbl">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Route</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{tasks === null ? (
<tr className="empty-row">
<td colSpan={4}>loading</td>
</tr>
) : tasks.length === 0 ? (
<tr className="empty-row">
<td colSpan={4}>no tasks yet create one above</td>
</tr>
) : (
tasks.map((t) => (
<tr key={t.id}>
<td className="num-cell">{t.id}</td>
<td>
<a className="rowlink" href={`#/tasks/${t.id}`}>
{t.name}
</a>
</td>
<td>
#{t.src_endpoint_id} #{t.dst_endpoint_id}
</td>
<td>
<StatusBadge status={t.status} />
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</>
)
}
+30
View File
@@ -0,0 +1,30 @@
// Live task event stream. One socket per task-detail view.
export type TaskEventType =
| 'run_started'
| 'account_started'
| 'account_test'
| 'progress'
| 'account_done'
| 'error'
| 'run_done'
| string
export interface TaskEvent {
type: TaskEventType
task_id: number
data: unknown
}
export function connectTaskWS(taskId: number, onEvent: (ev: TaskEvent) => void): () => void {
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
const ws = new WebSocket(`${proto}://${location.host}/ws?task_id=${taskId}`)
ws.onmessage = (m) => {
try {
onEvent(JSON.parse(m.data))
} catch {
// ignore malformed frames
}
}
return () => ws.close()
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+14
View File
@@ -0,0 +1,14 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
build: { outDir: 'dist' },
server: {
proxy: {
'/api': 'http://localhost:8080',
'/ws': { target: 'ws://localhost:8080', ws: true },
},
},
})