diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a08fbbd --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9b04974 --- /dev/null +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f28b0e5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.env +.env.* +!.env.example + +/internal/httpapi/webdist/* +!/internal/httpapi/webdist/index.html + +.DS_Store diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..f56e6ea --- /dev/null +++ b/Caddyfile @@ -0,0 +1,5 @@ +# Default profile: plain HTTP on :80, no TLS termination. +# Used by: docker compose up -d +:80 { + reverse_proxy app:8080 +} diff --git a/Caddyfile.tls b/Caddyfile.tls new file mode 100644 index 0000000..bb846ce --- /dev/null +++ b/Caddyfile.tls @@ -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 +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c076d39 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a17262f --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..bbdd6be --- /dev/null +++ b/README.md @@ -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`). diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..c97dcc4 --- /dev/null +++ b/cmd/server/main.go @@ -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 +} diff --git a/docker-compose.tls.yml b/docker-compose.tls.yml new file mode 100644 index 0000000..9effd73 --- /dev/null +++ b/docker-compose.tls.yml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..bcfdf36 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..fa3c0f4 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1b299c5 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..cb91069 --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..0c1945b --- /dev/null +++ b/internal/config/config_test.go @@ -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) + } +} diff --git a/internal/crypto/crypto.go b/internal/crypto/crypto.go new file mode 100644 index 0000000..0adf923 --- /dev/null +++ b/internal/crypto/crypto.go @@ -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) +} diff --git a/internal/crypto/crypto_test.go b/internal/crypto/crypto_test.go new file mode 100644 index 0000000..ca95e75 --- /dev/null +++ b/internal/crypto/crypto_test.go @@ -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)") + } +} diff --git a/internal/crypto/session.go b/internal/crypto/session.go new file mode 100644 index 0000000..2c938d0 --- /dev/null +++ b/internal/crypto/session.go @@ -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)) +} diff --git a/internal/crypto/session_test.go b/internal/crypto/session_test.go new file mode 100644 index 0000000..8d844f9 --- /dev/null +++ b/internal/crypto/session_test.go @@ -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") + } +} diff --git a/internal/csvimport/csvimport.go b/internal/csvimport/csvimport.go new file mode 100644 index 0000000..9d0c342 --- /dev/null +++ b/internal/csvimport/csvimport.go @@ -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 +} diff --git a/internal/csvimport/csvimport_test.go b/internal/csvimport/csvimport_test.go new file mode 100644 index 0000000..82279a3 --- /dev/null +++ b/internal/csvimport/csvimport_test.go @@ -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") + } +} diff --git a/internal/httpapi/accounts.go b/internal/httpapi/accounts.go new file mode 100644 index 0000000..09b0788 --- /dev/null +++ b/internal/httpapi/accounts.go @@ -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}) +} diff --git a/internal/httpapi/auth.go b/internal/httpapi/auth.go new file mode 100644 index 0000000..fd25688 --- /dev/null +++ b/internal/httpapi/auth.go @@ -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) + }) +} diff --git a/internal/httpapi/auth_test.go b/internal/httpapi/auth_test.go new file mode 100644 index 0000000..269c7f8 --- /dev/null +++ b/internal/httpapi/auth_test.go @@ -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) + } +} diff --git a/internal/httpapi/dto_test.go b/internal/httpapi/dto_test.go new file mode 100644 index 0000000..35bcf74 --- /dev/null +++ b/internal/httpapi/dto_test.go @@ -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) + } +} diff --git a/internal/httpapi/endpoints.go b/internal/httpapi/endpoints.go new file mode 100644 index 0000000..2bbf885 --- /dev/null +++ b/internal/httpapi/endpoints.go @@ -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) +} diff --git a/internal/httpapi/router.go b/internal/httpapi/router.go new file mode 100644 index 0000000..f33e774 --- /dev/null +++ b/internal/httpapi/router.go @@ -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 +} diff --git a/internal/httpapi/router_test.go b/internal/httpapi/router_test.go new file mode 100644 index 0000000..4826eb3 --- /dev/null +++ b/internal/httpapi/router_test.go @@ -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) + } +} diff --git a/internal/httpapi/run.go b/internal/httpapi/run.go new file mode 100644 index 0000000..b9b689a --- /dev/null +++ b/internal/httpapi/run.go @@ -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}) +} diff --git a/internal/httpapi/run_test.go b/internal/httpapi/run_test.go new file mode 100644 index 0000000..2e7e04b --- /dev/null +++ b/internal/httpapi/run_test.go @@ -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) + } +} diff --git a/internal/httpapi/static.go b/internal/httpapi/static.go new file mode 100644 index 0000000..beca83e --- /dev/null +++ b/internal/httpapi/static.go @@ -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 +} diff --git a/internal/httpapi/tasks.go b/internal/httpapi/tasks.go new file mode 100644 index 0000000..b1a84fe --- /dev/null +++ b/internal/httpapi/tasks.go @@ -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}) +} diff --git a/internal/httpapi/webdist/index.html b/internal/httpapi/webdist/index.html new file mode 100644 index 0000000..47567c2 --- /dev/null +++ b/internal/httpapi/webdist/index.html @@ -0,0 +1 @@ +imap-copier diff --git a/internal/httpapi/ws.go b/internal/httpapi/ws.go new file mode 100644 index 0000000..f75a9a1 --- /dev/null +++ b/internal/httpapi/ws.go @@ -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 + } + } +} diff --git a/internal/httpapi/ws_test.go b/internal/httpapi/ws_test.go new file mode 100644 index 0000000..34b6587 --- /dev/null +++ b/internal/httpapi/ws_test.go @@ -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) + } +} diff --git a/internal/imapx/account.go b/internal/imapx/account.go new file mode 100644 index 0000000..4b9792c --- /dev/null +++ b/internal/imapx/account.go @@ -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 +} diff --git a/internal/imapx/copy.go b/internal/imapx/copy.go new file mode 100644 index 0000000..46ac07f --- /dev/null +++ b/internal/imapx/copy.go @@ -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 +} diff --git a/internal/imapx/copy_test.go b/internal/imapx/copy_test.go new file mode 100644 index 0000000..6c73cee --- /dev/null +++ b/internal/imapx/copy_test.go @@ -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: \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) + } +} diff --git a/internal/imapx/dial.go b/internal/imapx/dial.go new file mode 100644 index 0000000..8de1b20 --- /dev/null +++ b/internal/imapx/dial.go @@ -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() +} diff --git a/internal/imapx/dial_test.go b/internal/imapx/dial_test.go new file mode 100644 index 0000000..f51a006 --- /dev/null +++ b/internal/imapx/dial_test.go @@ -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") + } +} diff --git a/internal/imapx/messagekey.go b/internal/imapx/messagekey.go new file mode 100644 index 0000000..09a50fe --- /dev/null +++ b/internal/imapx/messagekey.go @@ -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, ",") +} diff --git a/internal/imapx/messagekey_test.go b/internal/imapx/messagekey_test.go new file mode 100644 index 0000000..8d46c57 --- /dev/null +++ b/internal/imapx/messagekey_test.go @@ -0,0 +1,32 @@ +package imapx + +import ( + "testing" + "time" + + "github.com/emersion/go-imap/v2" +) + +func TestMessageKeyPrefersMessageID(t *testing.T) { + env := &imap.Envelope{MessageID: ""} + if got := MessageKey(env, 100); got != "" { + t.Fatalf("got %q, want ", 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") + } +} diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go new file mode 100644 index 0000000..5372f91 --- /dev/null +++ b/internal/orchestrator/orchestrator.go @@ -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 +} diff --git a/internal/orchestrator/orchestrator_test.go b/internal/orchestrator/orchestrator_test.go new file mode 100644 index 0000000..d83ea6c --- /dev/null +++ b/internal/orchestrator/orchestrator_test.go @@ -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") + } +} diff --git a/internal/store/accounts.go b/internal/store/accounts.go new file mode 100644 index 0000000..80041d6 --- /dev/null +++ b/internal/store/accounts.go @@ -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 +} diff --git a/internal/store/accounts_test.go b/internal/store/accounts_test.go new file mode 100644 index 0000000..07b4fa9 --- /dev/null +++ b/internal/store/accounts_test.go @@ -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", ""); err != nil { + t.Fatalf("mark: %v", err) + } + if err := s.MarkMigrated(ctx, accID, "INBOX", ""); err != nil { + t.Fatalf("second mark must not error (ON CONFLICT): %v", err) + } + ok, err := s.IsMigrated(ctx, accID, "") + if err != nil || !ok { + t.Fatalf("IsMigrated = %v,%v want true,nil", ok, err) + } + absent, _ := s.IsMigrated(ctx, accID, "") + if absent { + t.Fatal("unknown key must be false") + } +} diff --git a/internal/store/endpoints.go b/internal/store/endpoints.go new file mode 100644 index 0000000..aa08f07 --- /dev/null +++ b/internal/store/endpoints.go @@ -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() +} diff --git a/internal/store/json_test.go b/internal/store/json_test.go new file mode 100644 index 0000000..d050f69 --- /dev/null +++ b/internal/store/json_test.go @@ -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) + } +} diff --git a/internal/store/migrated.go b/internal/store/migrated.go new file mode 100644 index 0000000..5fb1d49 --- /dev/null +++ b/internal/store/migrated.go @@ -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 +} diff --git a/internal/store/runs.go b/internal/store/runs.go new file mode 100644 index 0000000..ac0075a --- /dev/null +++ b/internal/store/runs.go @@ -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 +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..70a6f09 --- /dev/null +++ b/internal/store/store.go @@ -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 +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..2a12c33 --- /dev/null +++ b/internal/store/store_test.go @@ -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) + } +} diff --git a/internal/store/tasks.go b/internal/store/tasks.go new file mode 100644 index 0000000..f4b13d3 --- /dev/null +++ b/internal/store/tasks.go @@ -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 +} diff --git a/internal/store/tasks_test.go b/internal/store/tasks_test.go new file mode 100644 index 0000000..78346b0 --- /dev/null +++ b/internal/store/tasks_test.go @@ -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") + } +} diff --git a/internal/wshub/wshub.go b/internal/wshub/wshub.go new file mode 100644 index 0000000..130b12a --- /dev/null +++ b/internal/wshub/wshub.go @@ -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: // медленный подписчик — событие дропаем, не блокируем воркер + } + } +} diff --git a/internal/wshub/wshub_test.go b/internal/wshub/wshub_test.go new file mode 100644 index 0000000..36bbea4 --- /dev/null +++ b/internal/wshub/wshub_test.go @@ -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): + } +} diff --git a/migrations/0001_init.down.sql b/migrations/0001_init.down.sql new file mode 100644 index 0000000..eb0e5c3 --- /dev/null +++ b/migrations/0001_init.down.sql @@ -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; diff --git a/migrations/0001_init.up.sql b/migrations/0001_init.up.sql new file mode 100644 index 0000000..461d558 --- /dev/null +++ b/migrations/0001_init.up.sql @@ -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) +); diff --git a/scripts/docker-compose.e2e.yml b/scripts/docker-compose.e2e.yml new file mode 100644 index 0000000..f429116 --- /dev/null +++ b/scripts/docker-compose.e2e.yml @@ -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 diff --git a/scripts/e2e.sh b/scripts/e2e.sh new file mode 100755 index 0000000..143b1c6 --- /dev/null +++ b/scripts/e2e.sh @@ -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" </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: \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)" diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/web/.gitignore @@ -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? diff --git a/web/.oxlintrc.json b/web/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/web/.oxlintrc.json @@ -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 }] + } +} diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..d6af7e3 --- /dev/null +++ b/web/README.md @@ -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. diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..f789588 --- /dev/null +++ b/web/index.html @@ -0,0 +1,14 @@ + + + + + + + + imap/copier — operator console + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..f393009 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,1439 @@ +{ + "name": "web", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "web", + "version": "0.0.0", + "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" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@fontsource/big-shoulders-display": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@fontsource/big-shoulders-display/-/big-shoulders-display-5.2.5.tgz", + "integrity": "sha512-qqqqNaT2DRcrpytJ82ZjFeDsQFdrncGna3OqLS+F9XwOS65rxOnXFBgnubh3hQVj8RzUS/LQNVtUXvdsZLKtkA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/jetbrains-mono": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz", + "integrity": "sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", + "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.72.0.tgz", + "integrity": "sha512-zhCmvn+1Mj3UchAc/90i99S0t7jJUsHmFVSPg4UWrjO8b8eaSGwscgO6QAUtvHBstkjQwBttQNswEnAF1mIQdA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.72.0.tgz", + "integrity": "sha512-mtH+aY/ozv1eZoCUC2owjFAtyNBKHpJHygKeEu9zXXnQGW1Q2/qOpvx+I+Lf23+TvTz66F4iiXUbl2cGvoLPCQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.72.0.tgz", + "integrity": "sha512-EvnajNPDtfknB3ZieeOOyDTwJn9QXDiwfnF4ZDQqART6RG6hjY4WigQcZdGoK2dkB3e1vrmEzN9aYbQCUkh/gQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.72.0.tgz", + "integrity": "sha512-ZkCdEa/G80A7vEHfeCDz/+L3m33DE73v32mDKhgOIgz8Uwf0DFcK7+uu6qC+7LEhmz5fpOe1osWKyjSNMydFIQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.72.0.tgz", + "integrity": "sha512-NroXv2vh+sxVY1uya/rM5pjhx1hm8BzlYpx9q67QP0Xhw5MH2bf5GJylpvLEC+781p1Xli/317EoV9AlGwViag==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.72.0.tgz", + "integrity": "sha512-0NDywYgfj279Ou/BcQuCYSj7NJwBfmWn5qc5uGO/Ny7fUWmXyIpvawqX/8acQlWG6IXelJsJhj+JAy6sjsKj0A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.72.0.tgz", + "integrity": "sha512-4vpXB06h65Ezsy4hRyrGjGrfa1SkVPii09yaajiYhmVpgsFiLD+KNxIx/BNAY+XiO+i1yqp9HHdwqM8VTqa5XQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.72.0.tgz", + "integrity": "sha512-immaN4g2ZGFiOkKrvRX9LvzZdd2GkQM5wR+UyzYyUuyhUTXGQ4HKUJH18xp4G8OfhCVaVAJfKZxwE1r8+4hhaQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.72.0.tgz", + "integrity": "sha512-JGHS9Mnr7iWyyLDxgCv1MhzVpAckgptg00F2gnxt/GD7lQ2SW1BRcxHqhSTaSdDpjWRrBkBxMMh4+Hn3aVtExg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.72.0.tgz", + "integrity": "sha512-AOYgBZqxNshrg83P9v0RYv+m8s10Cqkj4/PxXFDhcS3k7FqsIG5+CxErshZCIN7G8iy4Y+VGfAsuEdar8AcbBg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.72.0.tgz", + "integrity": "sha512-QMybPS5ij3/vrKG67mqzHwW++91sYxK/PPUVi6SBtNCEzW4niS52fVBdXbQ6nou0wWbUPEpx8Sl/ZjtgE3clXA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.72.0.tgz", + "integrity": "sha512-gOc3W7JV0PXRpIL7stUlLe3Wa9Gp0Kdlup87IT3gHDvPKck2xNgMIl/Gs2lldYY2lyXZDC4rWi3hmoLUobkgbQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.72.0.tgz", + "integrity": "sha512-rpGxph+FjjHcYI5q6uxB3Az+tnfmEnDbSA8+PK9ZE/VzyUAkvBOMeuY7ZQMhu5mpZH7YQDsTdW6Cx4kV/msc6w==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.72.0.tgz", + "integrity": "sha512-WND+uhf/Ko13SLqQMWQUgsZuLvYYEvL0ZKgg0tgGYfLqxG7l8Ju123fHDMJyYSDl5E3bUbpFUuii/OvMreFQzw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.72.0.tgz", + "integrity": "sha512-SrpbrUL70nG9vh6zP4/oKHWgLuHquwsr7MW9XOn0olBVgh10Uqr8qscKhQoBGEn6olK/IUpn5GSKcdQ5AjUhGA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.72.0.tgz", + "integrity": "sha512-qkrsEn6NmgFKr7U/QnezQMb+q/vzAy0Dd9Y95gQGQTyjzDLN+HRZMuM5u70iyH4nBLCfKBzhjMsYCehKay2jyg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.72.0.tgz", + "integrity": "sha512-LWR6ZlFZph+KPjXv8opgZsXRDCdrdQe8VL8Cg9zxCoBS73h6znzZpydVgmdnwj8mB9AuSM5jxEgDJDpQkjboeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.72.0.tgz", + "integrity": "sha512-yt6HEh7IsHvtjRWtmeZRX134eaXKHq5Gnqlf1xBJdJl1JtdoRUEJw3nAxpZoUDS860cX/foKbztO441anVBtVQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.72.0.tgz", + "integrity": "sha512-b2eKFD2hX7tIwmo/cyH6TDq8vzWRZ2qNHrzoGntUTmq0h3zQh/uX3eTSHCwI8OB/ADQfJCRelLItK8BsxuucDA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", + "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz", + "integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz", + "integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz", + "integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz", + "integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz", + "integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz", + "integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz", + "integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz", + "integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz", + "integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz", + "integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz", + "integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz", + "integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz", + "integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz", + "integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/node": { + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/oxlint": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.72.0.tgz", + "integrity": "sha512-1rhdZIP/EvoI91ABIwNU5Q8+bWf8mjrS5UzIOZld4d4bXxJvtlUhlQvaoTogIGin/qdErMOrwaIJvCSIAKTLhA==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.72.0", + "@oxlint/binding-android-arm64": "1.72.0", + "@oxlint/binding-darwin-arm64": "1.72.0", + "@oxlint/binding-darwin-x64": "1.72.0", + "@oxlint/binding-freebsd-x64": "1.72.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.72.0", + "@oxlint/binding-linux-arm-musleabihf": "1.72.0", + "@oxlint/binding-linux-arm64-gnu": "1.72.0", + "@oxlint/binding-linux-arm64-musl": "1.72.0", + "@oxlint/binding-linux-ppc64-gnu": "1.72.0", + "@oxlint/binding-linux-riscv64-gnu": "1.72.0", + "@oxlint/binding-linux-riscv64-musl": "1.72.0", + "@oxlint/binding-linux-s390x-gnu": "1.72.0", + "@oxlint/binding-linux-x64-gnu": "1.72.0", + "@oxlint/binding-linux-x64-musl": "1.72.0", + "@oxlint/binding-openharmony-arm64": "1.72.0", + "@oxlint/binding-win32-arm64-msvc": "1.72.0", + "@oxlint/binding-win32-ia32-msvc": "1.72.0", + "@oxlint/binding-win32-x64-msvc": "1.72.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=0.22.1", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/rolldown": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz", + "integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.137.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.3", + "@rolldown/binding-darwin-arm64": "1.1.3", + "@rolldown/binding-darwin-x64": "1.1.3", + "@rolldown/binding-freebsd-x64": "1.1.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", + "@rolldown/binding-linux-arm64-gnu": "1.1.3", + "@rolldown/binding-linux-arm64-musl": "1.1.3", + "@rolldown/binding-linux-ppc64-gnu": "1.1.3", + "@rolldown/binding-linux-s390x-gnu": "1.1.3", + "@rolldown/binding-linux-x64-gnu": "1.1.3", + "@rolldown/binding-linux-x64-musl": "1.1.3", + "@rolldown/binding-openharmony-arm64": "1.1.3", + "@rolldown/binding-wasm32-wasi": "1.1.3", + "@rolldown/binding-win32-arm64-msvc": "1.1.3", + "@rolldown/binding-win32-x64-msvc": "1.1.3" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.2.tgz", + "integrity": "sha512-6YYPbRXTxx6bRXmOn7XdnQAy5DQNHhDgtjhDHI13oe4pY93kkcdGJWxpGwOm++/Wh0QpQhDrpIoVMrmrsI5AGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..afe61d1 --- /dev/null +++ b/web/package.json @@ -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" + } +} diff --git a/web/public/favicon.svg b/web/public/favicon.svg new file mode 100644 index 0000000..0c5f8a5 --- /dev/null +++ b/web/public/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..f435c8d --- /dev/null +++ b/web/src/App.tsx @@ -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 (location.hash = '#/')} /> + } + + function handleLogout() { + logout() + .catch(() => {}) + .finally(() => (location.hash = '#/login')) + } + + return ( +
+
+
+ [IMAP/COPIER + ] +
+ +
+ session active +
+ +
+
+ {route.name === 'tasks' && } + {route.name === 'endpoints' && } + {route.name === 'task' && } + {route.name === 'notfound' && ( +
+

Unknown route.

+ + ← back to tasks + +
+ )} +
+
+ ) +} + +export default App diff --git a/web/src/api.ts b/web/src/api.ts new file mode 100644 index 0000000..8cf3310 --- /dev/null +++ b/web/src/api.ts @@ -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 +} + +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(path: string, opts: RequestInit = {}): Promise { + 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 + 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('/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('/api/tasks') + +export const getTask = (id: number) => api(`/api/tasks/${id}`) + +export const createTask = (body: { + name: string + src_endpoint_id: number + dst_endpoint_id: number + folder_mapping?: Record +}) => 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 }) +} diff --git a/web/src/app.css b/web/src/app.css new file mode 100644 index 0000000..ecc98f1 --- /dev/null +++ b/web/src/app.css @@ -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; +} diff --git a/web/src/components/StatusBadge.tsx b/web/src/components/StatusBadge.tsx new file mode 100644 index 0000000..ae478d8 --- /dev/null +++ b/web/src/components/StatusBadge.tsx @@ -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 ( + + + {s} + + ) +} diff --git a/web/src/index.css b/web/src/index.css new file mode 100644 index 0000000..c4b1a43 --- /dev/null +++ b/web/src/index.css @@ -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; +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..bef5202 --- /dev/null +++ b/web/src/main.tsx @@ -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( + + + , +) diff --git a/web/src/pages/Endpoints.tsx b/web/src/pages/Endpoints.tsx new file mode 100644 index 0000000..82c08a9 --- /dev/null +++ b/web/src/pages/Endpoints.tsx @@ -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(null) + const [form, setForm] = useState(emptyForm) + const [error, setError] = useState(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 ( + <> +
+

+ Endpoints /// mailbox servers +

+
+ +
+
+ Register endpoint +
+
+ + setForm({ ...form, role_label: e.target.value })} + required + /> +
+
+ + setForm({ ...form, host: e.target.value })} + required + /> +
+
+
+ + setForm({ ...form, port: e.target.value })} + required + /> +
+
+ + +
+
+ {error &&
{error}
} +
+ +
+
+
+ +
+ Registered ({endpoints?.length ?? 0}) +
+ + + + + + + + + + + + {endpoints === null ? ( + + + + ) : endpoints.length === 0 ? ( + + + + ) : ( + endpoints.map((ep) => ( + + + + + + + + )) + )} + +
IDRoleHostPortTLS
loading…
no endpoints registered yet
{ep.id}{ep.role_label}{ep.host}{ep.port}{ep.tls_mode}
+
+
+
+ + ) +} diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx new file mode 100644 index 0000000..197ed92 --- /dev/null +++ b/web/src/pages/Login.tsx @@ -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(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 ( +
+
+

+ [IMAP/COPIER] +

+

OPERATOR CONSOLE — AUTHENTICATE TO CONTINUE

+ +
+ + setUser(e.target.value)} + autoComplete="username" + spellCheck={false} + /> +
+
+ + setPass(e.target.value)} + autoComplete="current-password" + /> +
+ +
{error}
+
+
+ ) +} diff --git a/web/src/pages/TaskDetail.tsx b/web/src/pages/TaskDetail.tsx new file mode 100644 index 0000000..074fd41 --- /dev/null +++ b/web/src/pages/TaskDetail.tsx @@ -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(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(null) + const fileInputRef = useRef(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) { + 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 ( +
+

Task #{id} not found.

+ + ← back to tasks + +
+ ) + } + + if (!data) { + return
loading task #{id}…
+ } + + 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 ( + <> +
+
+ + ← all tasks + +

+ {task.name} /// task #{task.id} +

+
+ +
+ +
+ Run control +
+
+ {totals.copied} + copied +
+
+ {totals.skipped} + skipped +
+
+ {totals.errors} + errors +
+
+ {accounts.length} + accounts +
+
+ {error &&
{error}
} +
+ + + {!allTested && accounts.length > 0 && run unlocks once every account tests OK on both sides} +
+
+ +
+
+ Add account +
+
+
+ + setForm({ ...form, src_login: e.target.value })} + required + /> +
+
+ + setForm({ ...form, src_pass: e.target.value })} + required + /> +
+
+
+
+ + setForm({ ...form, dst_login: e.target.value })} + required + /> +
+
+ + setForm({ ...form, dst_pass: e.target.value })} + required + /> +
+
+
+ +
+
+ +
or bulk import
+
+ + columns: src_login, src_pass, dst_login, dst_pass +
+
+ +
+ Event log +
+ {log.length === 0 ? ( +
awaiting events over websocket…
+ ) : ( + log.map((l, i) => ( +
+ {l.type} + {l.text} +
+ )) + )} +
+
+
+ +
+ Accounts ({accounts.length}) +
+ + + + + + + + + + + + + + + {accounts.length === 0 ? ( + + + + ) : ( + accounts.map((a) => ( + + + + + + + + + + + )) + )} + +
SourceDestinationSrc testDst testStatusCopiedSkippedErrors
no accounts yet — add one or import a CSV above
{a.src_login}{a.dst_login} + + + + + + {a.copied}{a.skipped}{a.errors}
+
+
+ + ) +} diff --git a/web/src/pages/Tasks.tsx b/web/src/pages/Tasks.tsx new file mode 100644 index 0000000..d534480 --- /dev/null +++ b/web/src/pages/Tasks.tsx @@ -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(null) + const [endpoints, setEndpoints] = useState([]) + const [name, setName] = useState('') + const [srcId, setSrcId] = useState('') + const [dstId, setDstId] = useState('') + const [error, setError] = useState(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 ( + <> +
+

+ Migration tasks /// mailbox copy jobs +

+
+ +
+ New task + {endpoints.length < 2 ? ( +

+ Register at least two endpoints (source & destination) on the{' '} + + Endpoints + {' '} + screen before creating a task. +

+ ) : ( +
+
+ + setName(e.target.value)} placeholder="q3-office365-migration" required /> +
+
+
+ + +
+
+ + +
+
+ {error &&
{error}
} +
+ +
+
+ )} +
+ +
+ All tasks ({tasks?.length ?? 0}) +
+ + + + + + + + + + + {tasks === null ? ( + + + + ) : tasks.length === 0 ? ( + + + + ) : ( + tasks.map((t) => ( + + + + + + + )) + )} + +
IDNameRouteStatus
loading…
no tasks yet — create one above
{t.id} + + {t.name} + + + #{t.src_endpoint_id} → #{t.dst_endpoint_id} + + +
+
+
+ + ) +} diff --git a/web/src/ws.ts b/web/src/ws.ts new file mode 100644 index 0000000..49d2089 --- /dev/null +++ b/web/src/ws.ts @@ -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() +} diff --git a/web/tsconfig.app.json b/web/tsconfig.app.json new file mode 100644 index 0000000..6830b6f --- /dev/null +++ b/web/tsconfig.app.json @@ -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"] +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json new file mode 100644 index 0000000..8455dcb --- /dev/null +++ b/web/tsconfig.node.json @@ -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"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..367dcca --- /dev/null +++ b/web/vite.config.ts @@ -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 }, + }, + }, +})