Merge feat/imap-copier-mvp: imap-copier MVP
IMAP-to-IMAP mail migration tool: Go backend (streaming, non-destructive, idempotent copy), React realtime UI, Postgres, Docker+Caddy. Full-stack E2E proved idempotency; final review Critical (double-run race) fixed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MMHQTtnQtQqL8muAXHr9kd
This commit is contained in:
@@ -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
|
||||||
@@ -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=
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
/internal/httpapi/webdist/*
|
||||||
|
!/internal/httpapi/webdist/index.html
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Default profile: plain HTTP on :80, no TLS termination.
|
||||||
|
# Used by: docker compose up -d
|
||||||
|
:80 {
|
||||||
|
reverse_proxy app:8080
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# HTTPS profile: automatic Let's Encrypt certificate for $DOMAIN.
|
||||||
|
# Used by: docker compose -f docker-compose.yml -f docker-compose.tls.yml up -d
|
||||||
|
# Requires DOMAIN (and ACME_EMAIL) set in .env, and the server's :80/:443
|
||||||
|
# reachable from the internet for the ACME HTTP-01 challenge.
|
||||||
|
{
|
||||||
|
email {$ACME_EMAIL}
|
||||||
|
}
|
||||||
|
|
||||||
|
{$DOMAIN} {
|
||||||
|
reverse_proxy app:8080
|
||||||
|
}
|
||||||
+29
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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`).
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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:
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<!doctype html><title>imap-copier</title>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
package imapx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/emersion/go-imap/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seedInbox logs in as login/pass and APPENDs n minimal messages with unique
|
||||||
|
// Message-IDs into INBOX via a dedicated connection.
|
||||||
|
func seedInbox(t *testing.T, ep Endpoint, login, pass string, n int) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
c, err := Connect(ctx, ep)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seedInbox connect: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = c.Logout().Wait() }()
|
||||||
|
|
||||||
|
if err := c.Login(login, pass).Wait(); err != nil {
|
||||||
|
t.Fatalf("seedInbox login: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
msg := fmt.Sprintf(
|
||||||
|
"From: sender@localhost\r\nTo: %s\r\nSubject: seed %d\r\nMessage-Id: <seed-%d-%p@localhost>\r\n\r\nBody %d\r\n",
|
||||||
|
login, i, i, &i, i,
|
||||||
|
)
|
||||||
|
buf := []byte(msg)
|
||||||
|
appendCmd := c.Append("INBOX", int64(len(buf)), nil)
|
||||||
|
if _, err := appendCmd.Write(buf); err != nil {
|
||||||
|
t.Fatalf("seedInbox write %d: %v", i, err)
|
||||||
|
}
|
||||||
|
if err := appendCmd.Close(); err != nil {
|
||||||
|
t.Fatalf("seedInbox close %d: %v", i, err)
|
||||||
|
}
|
||||||
|
if _, err := appendCmd.Wait(); err != nil {
|
||||||
|
t.Fatalf("seedInbox append %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedInboxWithDate APPENDs a single message with a given subject and a
|
||||||
|
// KNOWN IMAP internal date (via AppendOptions.Time), so the test can assert
|
||||||
|
// the date survives the copy instead of silently becoming "now" on dst.
|
||||||
|
func seedInboxWithDate(t *testing.T, ep Endpoint, login, pass, subject string, when time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
c, err := Connect(ctx, ep)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seedInboxWithDate connect: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = c.Logout().Wait() }()
|
||||||
|
|
||||||
|
if err := c.Login(login, pass).Wait(); err != nil {
|
||||||
|
t.Fatalf("seedInboxWithDate login: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := fmt.Sprintf(
|
||||||
|
"From: sender@localhost\r\nTo: %s\r\nSubject: %s\r\nMessage-Id: <%s@localhost>\r\n\r\nBody\r\n",
|
||||||
|
login, subject, subject,
|
||||||
|
)
|
||||||
|
buf := []byte(msg)
|
||||||
|
appendCmd := c.Append("INBOX", int64(len(buf)), &imap.AppendOptions{Time: when})
|
||||||
|
if _, err := appendCmd.Write(buf); err != nil {
|
||||||
|
t.Fatalf("seedInboxWithDate write: %v", err)
|
||||||
|
}
|
||||||
|
if err := appendCmd.Close(); err != nil {
|
||||||
|
t.Fatalf("seedInboxWithDate close: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := appendCmd.Wait(); err != nil {
|
||||||
|
t.Fatalf("seedInboxWithDate append: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchInternalDateBySubject connects, selects INBOX and returns the
|
||||||
|
// InternalDate of the first message whose Envelope.Subject matches.
|
||||||
|
func fetchInternalDateBySubject(t *testing.T, ep Endpoint, login, pass, subject string) time.Time {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
c, err := Connect(ctx, ep)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("fetchInternalDateBySubject connect: %v", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = c.Logout().Wait() }()
|
||||||
|
|
||||||
|
if err := c.Login(login, pass).Wait(); err != nil {
|
||||||
|
t.Fatalf("fetchInternalDateBySubject login: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sel, err := c.Select("INBOX", &imap.SelectOptions{ReadOnly: true}).Wait()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("fetchInternalDateBySubject select: %v", err)
|
||||||
|
}
|
||||||
|
if sel.NumMessages == 0 {
|
||||||
|
t.Fatalf("fetchInternalDateBySubject: INBOX empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
set := imap.SeqSet{imap.SeqRange{Start: 1, Stop: sel.NumMessages}}
|
||||||
|
msgs, err := c.Fetch(set, &imap.FetchOptions{
|
||||||
|
Envelope: true, InternalDate: true,
|
||||||
|
}).Collect()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("fetchInternalDateBySubject fetch: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range msgs {
|
||||||
|
if m.Envelope != nil && m.Envelope.Subject == subject {
|
||||||
|
return m.InternalDate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("fetchInternalDateBySubject: subject %q not found among %d messages", subject, len(msgs))
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCopyFolderPreservesInternalDate proves CopyFolder threads the source
|
||||||
|
// message's IMAP internal date through to APPEND on dst, instead of letting
|
||||||
|
// dst stamp it with "now" at APPEND time.
|
||||||
|
func TestCopyFolderPreservesInternalDate(t *testing.T) {
|
||||||
|
ep := testEP(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
const subject = "datecheck"
|
||||||
|
knownTime := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||||
|
seedInboxWithDate(t, ep, "datesrc@localhost", "p", subject, knownTime)
|
||||||
|
|
||||||
|
src, err := Connect(ctx, ep)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = src.Logout().Wait() }()
|
||||||
|
if err := src.Login("datesrc@localhost", "p").Wait(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dst, err := Connect(ctx, ep)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = dst.Logout().Wait() }()
|
||||||
|
if err := dst.Login("datedst@localhost", "p").Wait(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
deps := CopyDeps{
|
||||||
|
IsMigrated: func(string) (bool, error) { return false, nil },
|
||||||
|
MarkMigrated: func(_, _ string) error { return nil },
|
||||||
|
OnProgress: func(_, _ int) {},
|
||||||
|
}
|
||||||
|
|
||||||
|
r, err := CopyFolder(ctx, src, dst, "INBOX", "INBOX", deps)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CopyFolder: %v", err)
|
||||||
|
}
|
||||||
|
if r.Copied != 1 {
|
||||||
|
t.Fatalf("copied=%d want 1", r.Copied)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := fetchInternalDateBySubject(t, ep, "datedst@localhost", "p", subject).UTC().Truncate(time.Second)
|
||||||
|
want := knownTime.Truncate(time.Second)
|
||||||
|
if !got.Equal(want) {
|
||||||
|
t.Fatalf("internal date not preserved: got %v want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Требует два ящика на greenmail. Первый запуск копирует N, второй — 0 (все skipped).
|
||||||
|
func TestCopyFolderIdempotent(t *testing.T) {
|
||||||
|
ep := testEP(t) // plain greenmail
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// подготовка: APPEND 2 письма в INBOX источника через отдельное соединение
|
||||||
|
seedInbox(t, ep, "src@localhost", "p", 2)
|
||||||
|
|
||||||
|
src, err := Connect(ctx, ep)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = src.Logout().Wait() }()
|
||||||
|
if err := src.Login("src@localhost", "p").Wait(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dst, err := Connect(ctx, ep)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = dst.Logout().Wait() }()
|
||||||
|
if err := dst.Login("dst@localhost", "p").Wait(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := map[string]bool{}
|
||||||
|
deps := CopyDeps{
|
||||||
|
IsMigrated: func(k string) (bool, error) { return seen[k], nil },
|
||||||
|
MarkMigrated: func(_, k string) error { seen[k] = true; return nil },
|
||||||
|
OnProgress: func(_, _ int) {},
|
||||||
|
}
|
||||||
|
|
||||||
|
r1, err := CopyFolder(ctx, src, dst, "INBOX", "INBOX", deps)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("run1: %v", err)
|
||||||
|
}
|
||||||
|
if r1.Copied != 2 {
|
||||||
|
t.Fatalf("run1 copied=%d want 2", r1.Copied)
|
||||||
|
}
|
||||||
|
|
||||||
|
r2, err := CopyFolder(ctx, src, dst, "INBOX", "INBOX", deps)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("run2: %v", err)
|
||||||
|
}
|
||||||
|
if r2.Copied != 0 || r2.Skipped != 2 {
|
||||||
|
t.Fatalf("run2 copied=%d skipped=%d want 0/2", r2.Copied, r2.Skipped)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, ",")
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package imapx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/emersion/go-imap/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMessageKeyPrefersMessageID(t *testing.T) {
|
||||||
|
env := &imap.Envelope{MessageID: "<abc@host>"}
|
||||||
|
if got := MessageKey(env, 100); got != "<abc@host>" {
|
||||||
|
t.Fatalf("got %q, want <abc@host>", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMessageKeyFallbackStable(t *testing.T) {
|
||||||
|
env := &imap.Envelope{
|
||||||
|
Subject: "Hi",
|
||||||
|
Date: time.Unix(1700000000, 0).UTC(),
|
||||||
|
From: []imap.Address{{Mailbox: "a", Host: "x.com"}},
|
||||||
|
To: []imap.Address{{Mailbox: "b", Host: "y.com"}},
|
||||||
|
}
|
||||||
|
k1 := MessageKey(env, 42)
|
||||||
|
k2 := MessageKey(env, 42)
|
||||||
|
if k1 != k2 {
|
||||||
|
t.Fatal("fallback key must be deterministic")
|
||||||
|
}
|
||||||
|
if MessageKey(env, 43) == k1 {
|
||||||
|
t.Fatal("different size must change key")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
package orchestrator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/vasyansk/imap-copier/internal/crypto"
|
||||||
|
"github.com/vasyansk/imap-copier/internal/imapx"
|
||||||
|
"github.com/vasyansk/imap-copier/internal/store"
|
||||||
|
"github.com/vasyansk/imap-copier/internal/wshub"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrNotTested = errors.New("accounts not fully tested")
|
||||||
|
var ErrAlreadyRunning = errors.New("task already running")
|
||||||
|
|
||||||
|
type Orchestrator struct {
|
||||||
|
store *store.Store
|
||||||
|
hub *wshub.Hub
|
||||||
|
encKey []byte
|
||||||
|
concurrency int
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(s *store.Store, hub *wshub.Hub, encKey []byte, concurrency int) *Orchestrator {
|
||||||
|
return &Orchestrator{store: s, hub: hub, encKey: encKey, concurrency: concurrency}
|
||||||
|
}
|
||||||
|
|
||||||
|
func gateOK(accs []store.Account) bool {
|
||||||
|
if len(accs) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, a := range accs {
|
||||||
|
if a.TestSrcStatus != "ok" || a.TestDstStatus != "ok" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *Orchestrator) endpoints(ctx context.Context, task store.Task) (imapx.Endpoint, imapx.Endpoint, error) {
|
||||||
|
src, err := o.store.GetEndpoint(ctx, task.SrcEndpointID)
|
||||||
|
if err != nil {
|
||||||
|
return imapx.Endpoint{}, imapx.Endpoint{}, err
|
||||||
|
}
|
||||||
|
dst, err := o.store.GetEndpoint(ctx, task.DstEndpointID)
|
||||||
|
if err != nil {
|
||||||
|
return imapx.Endpoint{}, imapx.Endpoint{}, err
|
||||||
|
}
|
||||||
|
toEP := func(e store.Endpoint) imapx.Endpoint {
|
||||||
|
return imapx.Endpoint{Host: e.Host, Port: e.Port, TLSMode: e.TLSMode}
|
||||||
|
}
|
||||||
|
return toEP(src), toEP(dst), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *Orchestrator) TestAccounts(ctx context.Context, taskID int64) error {
|
||||||
|
task, err := o.store.GetTask(ctx, taskID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
srcEP, dstEP, err := o.endpoints(ctx, task)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
accs, err := o.store.ListAccountsByTask(ctx, taskID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, a := range accs {
|
||||||
|
o.testSide(ctx, srcEP, a.ID, "src", a.SrcLogin, a.SrcPassEnc, taskID)
|
||||||
|
o.testSide(ctx, dstEP, a.ID, "dst", a.DstLogin, a.DstPassEnc, taskID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *Orchestrator) testSide(ctx context.Context, ep imapx.Endpoint, accID int64, side, login, passEnc string, taskID int64) {
|
||||||
|
status := "ok"
|
||||||
|
pass, err := crypto.Decrypt(o.encKey, passEnc)
|
||||||
|
if err == nil {
|
||||||
|
_, err = imapx.TestLogin(ctx, ep, login, string(pass))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
status = "fail"
|
||||||
|
slog.Warn("account test failed", "account", accID, "side", side, "err", err)
|
||||||
|
}
|
||||||
|
_ = o.store.SetAccountTestStatus(ctx, accID, side, status)
|
||||||
|
o.hub.Publish(wshub.Event{Type: "account_test", TaskID: taskID,
|
||||||
|
Data: map[string]any{"account_id": accID, "side": side, "status": status}})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *Orchestrator) Run(ctx context.Context, taskID int64) (int64, error) {
|
||||||
|
task, err := o.store.GetTask(ctx, taskID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
accs, err := o.store.ListAccountsByTask(ctx, taskID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if !gateOK(accs) {
|
||||||
|
return 0, ErrNotTested
|
||||||
|
}
|
||||||
|
acquired, err := o.store.TryMarkTaskRunning(ctx, taskID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if !acquired {
|
||||||
|
return 0, ErrAlreadyRunning
|
||||||
|
}
|
||||||
|
srcEP, dstEP, err := o.endpoints(ctx, task)
|
||||||
|
if err != nil {
|
||||||
|
_ = o.store.SetTaskStatus(ctx, taskID, "error")
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
runID, err := o.store.CreateRun(ctx, taskID)
|
||||||
|
if err != nil {
|
||||||
|
_ = o.store.SetTaskStatus(ctx, taskID, "error")
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
o.hub.Publish(wshub.Event{Type: "run_started", TaskID: taskID, Data: map[string]any{"run_id": runID}})
|
||||||
|
|
||||||
|
go o.runAll(context.WithoutCancel(ctx), task, runID, accs, srcEP, dstEP)
|
||||||
|
return runID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *Orchestrator) runAll(ctx context.Context, task store.Task, runID int64, accs []store.Account, srcEP, dstEP imapx.Endpoint) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
slog.Error("run coordinator panicked", "task", task.ID, "run", runID, "panic", r)
|
||||||
|
_ = o.store.FinishRun(ctx, runID, "error", 0, 0, 0)
|
||||||
|
_ = o.store.SetTaskStatus(ctx, task.ID, "error")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
totCopied, totSkipped, totErr int64
|
||||||
|
)
|
||||||
|
sem := make(chan struct{}, o.concurrency)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for _, a := range accs {
|
||||||
|
wg.Add(1)
|
||||||
|
sem <- struct{}{}
|
||||||
|
go func(a store.Account) {
|
||||||
|
defer wg.Done()
|
||||||
|
defer func() { <-sem }()
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
slog.Error("account worker panicked", "task", task.ID, "account", a.ID, "panic", r)
|
||||||
|
_ = o.store.SetAccountStatus(ctx, a.ID, "error")
|
||||||
|
o.hub.Publish(wshub.Event{Type: "error", TaskID: task.ID,
|
||||||
|
Data: map[string]any{"account_id": a.ID, "error": "internal panic"}})
|
||||||
|
mu.Lock()
|
||||||
|
totErr++
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
c, s, e := o.runAccount(ctx, task, runID, a, srcEP, dstEP)
|
||||||
|
mu.Lock()
|
||||||
|
totCopied += c
|
||||||
|
totSkipped += s
|
||||||
|
totErr += e
|
||||||
|
mu.Unlock()
|
||||||
|
}(a)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
status := "done"
|
||||||
|
if totErr > 0 {
|
||||||
|
status = "done_with_errors"
|
||||||
|
}
|
||||||
|
_ = o.store.FinishRun(ctx, runID, status, totCopied, totSkipped, totErr)
|
||||||
|
_ = o.store.SetTaskStatus(ctx, task.ID, status)
|
||||||
|
o.hub.Publish(wshub.Event{Type: "run_done", TaskID: task.ID,
|
||||||
|
Data: map[string]any{"run_id": runID, "copied": totCopied, "skipped": totSkipped, "errors": totErr}})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *Orchestrator) runAccount(ctx context.Context, task store.Task, runID int64, a store.Account, srcEP, dstEP imapx.Endpoint) (int64, int64, int64) {
|
||||||
|
o.hub.Publish(wshub.Event{Type: "account_started", TaskID: task.ID, Data: map[string]any{"account_id": a.ID}})
|
||||||
|
_ = o.store.SetAccountStatus(ctx, a.ID, "running")
|
||||||
|
|
||||||
|
srcPass, err := crypto.Decrypt(o.encKey, a.SrcPassEnc)
|
||||||
|
if err != nil {
|
||||||
|
return o.accountFailed(ctx, task.ID, a.ID, err)
|
||||||
|
}
|
||||||
|
dstPass, err := crypto.Decrypt(o.encKey, a.DstPassEnc)
|
||||||
|
if err != nil {
|
||||||
|
return o.accountFailed(ctx, task.ID, a.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
src, err := imapx.Connect(ctx, srcEP)
|
||||||
|
if err != nil {
|
||||||
|
return o.accountFailed(ctx, task.ID, a.ID, err)
|
||||||
|
}
|
||||||
|
defer func() { _ = src.Logout().Wait() }()
|
||||||
|
if err := src.Login(a.SrcLogin, string(srcPass)).Wait(); err != nil {
|
||||||
|
return o.accountFailed(ctx, task.ID, a.ID, err)
|
||||||
|
}
|
||||||
|
dst, err := imapx.Connect(ctx, dstEP)
|
||||||
|
if err != nil {
|
||||||
|
return o.accountFailed(ctx, task.ID, a.ID, err)
|
||||||
|
}
|
||||||
|
defer func() { _ = dst.Logout().Wait() }()
|
||||||
|
if err := dst.Login(a.DstLogin, string(dstPass)).Wait(); err != nil {
|
||||||
|
return o.accountFailed(ctx, task.ID, a.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
folders, err := imapx.ListFolders(src)
|
||||||
|
if err != nil {
|
||||||
|
return o.accountFailed(ctx, task.ID, a.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var copied, skipped, errs int64
|
||||||
|
deps := imapx.CopyDeps{
|
||||||
|
IsMigrated: func(k string) (bool, error) { return o.store.IsMigrated(ctx, a.ID, k) },
|
||||||
|
MarkMigrated: func(folder, k string) error { return o.store.MarkMigrated(ctx, a.ID, folder, k) },
|
||||||
|
OnProgress: func(c, s int) {
|
||||||
|
o.hub.Publish(wshub.Event{Type: "progress", TaskID: task.ID,
|
||||||
|
Data: map[string]any{"account_id": a.ID, "copied": c, "skipped": s}})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, folder := range folders {
|
||||||
|
dstFolder := folder
|
||||||
|
if m, ok := task.FolderMapping[folder]; ok {
|
||||||
|
dstFolder = m
|
||||||
|
}
|
||||||
|
res, err := imapx.CopyFolder(ctx, src, dst, folder, dstFolder, deps)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("folder copy error", "account", a.ID, "folder", folder, "err", err)
|
||||||
|
errs++
|
||||||
|
}
|
||||||
|
copied += int64(res.Copied)
|
||||||
|
skipped += int64(res.Skipped)
|
||||||
|
errs += int64(res.Errors)
|
||||||
|
_ = o.store.IncAccountCounters(ctx, a.ID, int64(res.Copied), int64(res.Skipped), int64(res.Errors))
|
||||||
|
}
|
||||||
|
_ = o.store.SetAccountStatus(ctx, a.ID, "done")
|
||||||
|
o.hub.Publish(wshub.Event{Type: "account_done", TaskID: task.ID,
|
||||||
|
Data: map[string]any{"account_id": a.ID, "copied": copied, "skipped": skipped, "errors": errs}})
|
||||||
|
slog.Info("account copied", "account", a.ID, "copied", copied, "skipped", skipped, "errors", errs)
|
||||||
|
return copied, skipped, errs
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *Orchestrator) accountFailed(ctx context.Context, taskID, accID int64, err error) (int64, int64, int64) {
|
||||||
|
slog.Error("account failed", "account", accID, "err", err)
|
||||||
|
_ = o.store.SetAccountStatus(ctx, accID, "error")
|
||||||
|
o.hub.Publish(wshub.Event{Type: "error", TaskID: taskID,
|
||||||
|
Data: map[string]any{"account_id": accID, "error": err.Error()}})
|
||||||
|
return 0, 0, 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package orchestrator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/vasyansk/imap-copier/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGateOK(t *testing.T) {
|
||||||
|
ok := []store.Account{
|
||||||
|
{TestSrcStatus: "ok", TestDstStatus: "ok"},
|
||||||
|
{TestSrcStatus: "ok", TestDstStatus: "ok"},
|
||||||
|
}
|
||||||
|
if !gateOK(ok) {
|
||||||
|
t.Fatal("all ok must pass gate")
|
||||||
|
}
|
||||||
|
bad := []store.Account{{TestSrcStatus: "ok", TestDstStatus: "fail"}}
|
||||||
|
if gateOK(bad) {
|
||||||
|
t.Fatal("any non-ok must fail gate")
|
||||||
|
}
|
||||||
|
if gateOK(nil) {
|
||||||
|
t.Fatal("empty accounts must fail gate")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMigratedIdempotency(t *testing.T) {
|
||||||
|
s := testStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
epSrc, _ := s.CreateEndpoint(ctx, Endpoint{RoleLabel: "src", Host: "a", Port: 993, TLSMode: "ssl"})
|
||||||
|
epDst, _ := s.CreateEndpoint(ctx, Endpoint{RoleLabel: "dst", Host: "b", Port: 993, TLSMode: "ssl"})
|
||||||
|
taskID, _ := s.CreateTask(ctx, Task{Name: "t", SrcEndpointID: epSrc, DstEndpointID: epDst})
|
||||||
|
accID, _ := s.CreateAccount(ctx, Account{TaskID: taskID, SrcLogin: "u", SrcPassEnc: "x", DstLogin: "u2", DstPassEnc: "y"})
|
||||||
|
|
||||||
|
if err := s.MarkMigrated(ctx, accID, "INBOX", "<msg-1>"); err != nil {
|
||||||
|
t.Fatalf("mark: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.MarkMigrated(ctx, accID, "INBOX", "<msg-1>"); err != nil {
|
||||||
|
t.Fatalf("second mark must not error (ON CONFLICT): %v", err)
|
||||||
|
}
|
||||||
|
ok, err := s.IsMigrated(ctx, accID, "<msg-1>")
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("IsMigrated = %v,%v want true,nil", ok, err)
|
||||||
|
}
|
||||||
|
absent, _ := s.IsMigrated(ctx, accID, "<msg-2>")
|
||||||
|
if absent {
|
||||||
|
t.Fatal("unknown key must be false")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: // медленный подписчик — событие дропаем, не блокируем воркер
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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):
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Override used only by scripts/e2e.sh: adds a greenmail IMAP server that
|
||||||
|
# acts as BOTH the source and the destination (two mailboxes, one server),
|
||||||
|
# reachable from the app container via compose DNS as host "greenmail".
|
||||||
|
services:
|
||||||
|
greenmail:
|
||||||
|
image: greenmail/standalone:latest
|
||||||
|
environment:
|
||||||
|
GREENMAIL_OPTS: >-
|
||||||
|
-Dgreenmail.setup.test.all
|
||||||
|
-Dgreenmail.hostname=0.0.0.0
|
||||||
|
-Dgreenmail.auth.disabled
|
||||||
|
-Dgreenmail.verbose
|
||||||
|
ports:
|
||||||
|
# exposed to the host so the seeding script (running outside docker)
|
||||||
|
# can APPEND test messages into the source mailbox
|
||||||
|
- "3143:3143"
|
||||||
|
|
||||||
|
app:
|
||||||
|
depends_on:
|
||||||
|
greenmail:
|
||||||
|
condition: service_started
|
||||||
Executable
+200
@@ -0,0 +1,200 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# End-to-end test against the full docker-compose stack:
|
||||||
|
# postgres + app (this repo's image) + caddy + a greenmail server that plays
|
||||||
|
# BOTH the source and destination IMAP endpoint (two mailboxes on one host).
|
||||||
|
#
|
||||||
|
# Drives the real REST API: login -> create endpoints -> create task ->
|
||||||
|
# add account -> /test -> poll -> /run -> poll -> assert copied>0.
|
||||||
|
# Then runs /run a SECOND time and asserts it copies nothing new (idempotency
|
||||||
|
# via Message-ID dedup), proving the full stack end-to-end.
|
||||||
|
#
|
||||||
|
# Usage: bash scripts/e2e.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
PROJECT="imapcopier-e2e"
|
||||||
|
ENV_FILE="$ROOT_DIR/.env.e2e"
|
||||||
|
COMPOSE=(docker compose -p "$PROJECT" --env-file "$ENV_FILE" -f docker-compose.yml -f scripts/docker-compose.e2e.yml)
|
||||||
|
|
||||||
|
HTTP_PORT=8089
|
||||||
|
BASE="http://localhost:${HTTP_PORT}"
|
||||||
|
AUTH_USER="e2e"
|
||||||
|
AUTH_PASS="e2e-$(openssl rand -hex 8)"
|
||||||
|
COOKIE_JAR="$(mktemp -t imapcopier-e2e-cookies.XXXXXX)"
|
||||||
|
SEED_PY="$(mktemp -t imapcopier-e2e-seed.XXXXXX.py)"
|
||||||
|
|
||||||
|
SRC_USER="src1@example.com"
|
||||||
|
DST_USER="dst1@example.com"
|
||||||
|
MAIL_PASS="anypass"
|
||||||
|
|
||||||
|
log() { echo "[e2e] $*"; }
|
||||||
|
fail() { echo "[e2e] FAIL: $*"; exit 1; }
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
log "cleaning up (containers, volumes, temp files)"
|
||||||
|
"${COMPOSE[@]}" down -v --remove-orphans >/dev/null 2>&1 || true
|
||||||
|
rm -f "$ENV_FILE" "$COOKIE_JAR" "$SEED_PY"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
log "writing ephemeral env file $ENV_FILE"
|
||||||
|
cat > "$ENV_FILE" <<EOF
|
||||||
|
POSTGRES_PASSWORD=e2e-$(openssl rand -hex 12)
|
||||||
|
AUTH_USER=${AUTH_USER}
|
||||||
|
AUTH_PASS=${AUTH_PASS}
|
||||||
|
ENC_KEY=$(openssl rand -base64 32)
|
||||||
|
SESSION_SECRET=e2e-$(openssl rand -hex 24)
|
||||||
|
WORKER_CONCURRENCY=4
|
||||||
|
HTTP_PORT=${HTTP_PORT}
|
||||||
|
DOMAIN=
|
||||||
|
ACME_EMAIL=
|
||||||
|
EOF
|
||||||
|
|
||||||
|
log "tearing down any previous e2e stack"
|
||||||
|
"${COMPOSE[@]}" down -v --remove-orphans >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
log "building and starting stack (postgres, app, caddy, greenmail)"
|
||||||
|
"${COMPOSE[@]}" up -d --build
|
||||||
|
|
||||||
|
wait_for() {
|
||||||
|
local desc="$1" tries="$2"; shift 2
|
||||||
|
for ((i = 1; i <= tries; i++)); do
|
||||||
|
if "$@" >/dev/null 2>&1; then
|
||||||
|
log "$desc: ready"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
fail "$desc: timed out after ${tries}s"
|
||||||
|
}
|
||||||
|
|
||||||
|
log "waiting for app healthz"
|
||||||
|
wait_for "app /healthz" 60 curl -fsS "$BASE/healthz"
|
||||||
|
|
||||||
|
log "waiting for greenmail IMAP port"
|
||||||
|
wait_for "greenmail:3143" 30 bash -c "exec 3<>/dev/tcp/127.0.0.1/3143"
|
||||||
|
|
||||||
|
log "seeding two messages into ${SRC_USER} INBOX"
|
||||||
|
cat > "$SEED_PY" <<'PYEOF'
|
||||||
|
import imaplib, sys, time
|
||||||
|
|
||||||
|
host, port, user, password, count = sys.argv[1], int(sys.argv[2]), sys.argv[3], sys.argv[4], int(sys.argv[5])
|
||||||
|
m = imaplib.IMAP4(host, port)
|
||||||
|
m.login(user, password)
|
||||||
|
m.select("INBOX")
|
||||||
|
for i in range(count):
|
||||||
|
msg = (
|
||||||
|
f"Message-ID: <e2e-{i}-{int(time.time())}@example.com>\r\n"
|
||||||
|
f"From: sender@example.com\r\n"
|
||||||
|
f"To: {user}\r\n"
|
||||||
|
f"Subject: e2e test message {i}\r\n"
|
||||||
|
f"\r\n"
|
||||||
|
f"body {i}\r\n"
|
||||||
|
).encode()
|
||||||
|
typ, data = m.append("INBOX", None, imaplib.Time2Internaldate(time.time()), msg)
|
||||||
|
if typ != "OK":
|
||||||
|
print(f"append failed: {typ} {data}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
m.logout()
|
||||||
|
print("seeded", count, "messages")
|
||||||
|
PYEOF
|
||||||
|
python3 "$SEED_PY" 127.0.0.1 3143 "$SRC_USER" "$MAIL_PASS" 2
|
||||||
|
|
||||||
|
api() {
|
||||||
|
local method="$1" path="$2" data="${3:-}"
|
||||||
|
if [[ -n "$data" ]]; then
|
||||||
|
curl -fsS -b "$COOKIE_JAR" -c "$COOKIE_JAR" -X "$method" "$BASE$path" \
|
||||||
|
-H 'Content-Type: application/json' -d "$data"
|
||||||
|
else
|
||||||
|
curl -fsS -b "$COOKIE_JAR" -c "$COOKIE_JAR" -X "$method" "$BASE$path"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
log "logging in as ${AUTH_USER}"
|
||||||
|
curl -fsS -c "$COOKIE_JAR" -X POST "$BASE/api/login" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "{\"user\":\"${AUTH_USER}\",\"pass\":\"${AUTH_PASS}\"}" >/dev/null
|
||||||
|
|
||||||
|
log "creating src/dst endpoints (both point at greenmail:3143)"
|
||||||
|
SRC_EP_ID=$(api POST /api/endpoints '{"role_label":"src","host":"greenmail","port":3143,"tls_mode":"plain"}' | jq -r .id)
|
||||||
|
DST_EP_ID=$(api POST /api/endpoints '{"role_label":"dst","host":"greenmail","port":3143,"tls_mode":"plain"}' | jq -r .id)
|
||||||
|
[[ "$SRC_EP_ID" =~ ^[0-9]+$ ]] || fail "bad src endpoint id: $SRC_EP_ID"
|
||||||
|
[[ "$DST_EP_ID" =~ ^[0-9]+$ ]] || fail "bad dst endpoint id: $DST_EP_ID"
|
||||||
|
log "src_endpoint_id=$SRC_EP_ID dst_endpoint_id=$DST_EP_ID"
|
||||||
|
|
||||||
|
log "creating task"
|
||||||
|
TASK_ID=$(api POST /api/tasks "{\"name\":\"e2e\",\"src_endpoint_id\":${SRC_EP_ID},\"dst_endpoint_id\":${DST_EP_ID},\"folder_mapping\":{}}" | jq -r .id)
|
||||||
|
[[ "$TASK_ID" =~ ^[0-9]+$ ]] || fail "bad task id: $TASK_ID"
|
||||||
|
log "task_id=$TASK_ID"
|
||||||
|
|
||||||
|
log "adding account (src -> dst)"
|
||||||
|
ACCOUNT_ID=$(api POST "/api/tasks/${TASK_ID}/accounts" \
|
||||||
|
"{\"src_login\":\"${SRC_USER}\",\"src_pass\":\"${MAIL_PASS}\",\"dst_login\":\"${DST_USER}\",\"dst_pass\":\"${MAIL_PASS}\"}" | jq -r .id)
|
||||||
|
[[ "$ACCOUNT_ID" =~ ^[0-9]+$ ]] || fail "bad account id: $ACCOUNT_ID"
|
||||||
|
log "account_id=$ACCOUNT_ID"
|
||||||
|
|
||||||
|
log "POST /test"
|
||||||
|
api POST "/api/tasks/${TASK_ID}/test" >/dev/null
|
||||||
|
|
||||||
|
wait_test_ok() {
|
||||||
|
for ((i = 1; i <= 30; i++)); do
|
||||||
|
local res src dst
|
||||||
|
res=$(api GET "/api/tasks/${TASK_ID}")
|
||||||
|
src=$(echo "$res" | jq -r '.accounts[0].test_src_status')
|
||||||
|
dst=$(echo "$res" | jq -r '.accounts[0].test_dst_status')
|
||||||
|
if [[ "$src" == "ok" && "$dst" == "ok" ]]; then
|
||||||
|
log "connection tests: src=$src dst=$dst"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [[ "$src" == "fail" || "$dst" == "fail" ]]; then
|
||||||
|
fail "connection test failed: src=$src dst=$dst"
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
fail "connection tests did not complete in time"
|
||||||
|
}
|
||||||
|
wait_test_ok
|
||||||
|
|
||||||
|
wait_run_done() {
|
||||||
|
for ((i = 1; i <= 60; i++)); do
|
||||||
|
local status
|
||||||
|
status=$(api GET "/api/tasks/${TASK_ID}" | jq -r '.task.status')
|
||||||
|
if [[ "$status" == "done" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
fail "run did not finish in time (last status=$status)"
|
||||||
|
}
|
||||||
|
|
||||||
|
log "POST /run (first run)"
|
||||||
|
api POST "/api/tasks/${TASK_ID}/run" >/dev/null
|
||||||
|
wait_run_done
|
||||||
|
|
||||||
|
RES1=$(api GET "/api/tasks/${TASK_ID}")
|
||||||
|
RUN1_COPIED=$(echo "$RES1" | jq -r '.accounts[0].copied')
|
||||||
|
RUN1_SKIPPED=$(echo "$RES1" | jq -r '.accounts[0].skipped')
|
||||||
|
RUN1_ERRORS=$(echo "$RES1" | jq -r '.accounts[0].errors')
|
||||||
|
log "run 1: copied=$RUN1_COPIED skipped=$RUN1_SKIPPED errors=$RUN1_ERRORS"
|
||||||
|
[[ "$RUN1_ERRORS" == "0" ]] || fail "run 1 had errors"
|
||||||
|
[[ "$RUN1_COPIED" -gt 0 ]] || fail "run 1 copied nothing (expected >0)"
|
||||||
|
|
||||||
|
log "POST /run (second run, expect idempotency)"
|
||||||
|
api POST "/api/tasks/${TASK_ID}/run" >/dev/null
|
||||||
|
wait_run_done
|
||||||
|
|
||||||
|
RES2=$(api GET "/api/tasks/${TASK_ID}")
|
||||||
|
RUN2_COPIED_TOTAL=$(echo "$RES2" | jq -r '.accounts[0].copied')
|
||||||
|
RUN2_SKIPPED_TOTAL=$(echo "$RES2" | jq -r '.accounts[0].skipped')
|
||||||
|
RUN2_ERRORS=$(echo "$RES2" | jq -r '.accounts[0].errors')
|
||||||
|
RUN2_COPIED_DELTA=$((RUN2_COPIED_TOTAL - RUN1_COPIED))
|
||||||
|
RUN2_SKIPPED_DELTA=$((RUN2_SKIPPED_TOTAL - RUN1_SKIPPED))
|
||||||
|
log "run 2: copied_delta=$RUN2_COPIED_DELTA skipped_delta=$RUN2_SKIPPED_DELTA errors=$RUN2_ERRORS"
|
||||||
|
|
||||||
|
[[ "$RUN2_ERRORS" == "0" ]] || fail "run 2 had errors"
|
||||||
|
[[ "$RUN2_COPIED_DELTA" -eq 0 ]] || fail "run 2 copied $RUN2_COPIED_DELTA new messages (expected 0, not idempotent)"
|
||||||
|
[[ "$RUN2_SKIPPED_DELTA" -gt 0 ]] || fail "run 2 skipped delta is $RUN2_SKIPPED_DELTA (expected >0)"
|
||||||
|
|
||||||
|
log "PASS: run1 copied=$RUN1_COPIED skipped=$RUN1_SKIPPED; run2 copied=$RUN2_COPIED_DELTA skipped=$RUN2_SKIPPED_DELTA (idempotent)"
|
||||||
@@ -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?
|
||||||
@@ -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 }]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="color-scheme" content="dark" />
|
||||||
|
<title>imap/copier — operator console</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+1439
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48">
|
||||||
|
<rect width="48" height="48" rx="6" fill="#0a0f0d"/>
|
||||||
|
<path d="M10 15 L20 24 L10 33" fill="none" stroke="#ffb238" stroke-width="4" stroke-linecap="square" stroke-linejoin="miter"/>
|
||||||
|
<rect x="24" y="30" width="14" height="4" fill="#ffb238"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 336 B |
@@ -0,0 +1,88 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import './app.css'
|
||||||
|
import { logout } from './api'
|
||||||
|
import { Login } from './pages/Login'
|
||||||
|
import { Endpoints } from './pages/Endpoints'
|
||||||
|
import { Tasks } from './pages/Tasks'
|
||||||
|
import { TaskDetail } from './pages/TaskDetail'
|
||||||
|
|
||||||
|
type Route =
|
||||||
|
| { name: 'login' }
|
||||||
|
| { name: 'tasks' }
|
||||||
|
| { name: 'endpoints' }
|
||||||
|
| { name: 'task'; id: number }
|
||||||
|
| { name: 'notfound' }
|
||||||
|
|
||||||
|
function parseRoute(hash: string): Route {
|
||||||
|
const path = hash.replace(/^#/, '') || '/'
|
||||||
|
if (path === '/login') return { name: 'login' }
|
||||||
|
if (path === '/') return { name: 'tasks' }
|
||||||
|
if (path === '/endpoints') return { name: 'endpoints' }
|
||||||
|
const m = path.match(/^\/tasks\/(\d+)$/)
|
||||||
|
if (m) return { name: 'task', id: Number(m[1]) }
|
||||||
|
return { name: 'notfound' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function useHashRoute(): Route {
|
||||||
|
const [hash, setHash] = useState(location.hash)
|
||||||
|
useEffect(() => {
|
||||||
|
const onChange = () => setHash(location.hash)
|
||||||
|
window.addEventListener('hashchange', onChange)
|
||||||
|
return () => window.removeEventListener('hashchange', onChange)
|
||||||
|
}, [])
|
||||||
|
return parseRoute(hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const route = useHashRoute()
|
||||||
|
|
||||||
|
if (route.name === 'login') {
|
||||||
|
return <Login onSuccess={() => (location.hash = '#/')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
logout()
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => (location.hash = '#/login'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="shell">
|
||||||
|
<header className="topbar">
|
||||||
|
<div className="brand">
|
||||||
|
<span className="bracket">[</span>IMAP<span className="dim">/COPIER</span>
|
||||||
|
<span className="bracket">]</span>
|
||||||
|
</div>
|
||||||
|
<nav className="topnav">
|
||||||
|
<a href="#/" className={route.name === 'tasks' || route.name === 'task' ? 'active' : ''}>
|
||||||
|
Tasks
|
||||||
|
</a>
|
||||||
|
<a href="#/endpoints" className={route.name === 'endpoints' ? 'active' : ''}>
|
||||||
|
Endpoints
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
<div className="session-indicator">
|
||||||
|
<span className="pulse-dot" /> session active
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-ghost" onClick={handleLogout}>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<main className="main">
|
||||||
|
{route.name === 'tasks' && <Tasks />}
|
||||||
|
{route.name === 'endpoints' && <Endpoints />}
|
||||||
|
{route.name === 'task' && <TaskDetail id={route.id} />}
|
||||||
|
{route.name === 'notfound' && (
|
||||||
|
<div className="panel">
|
||||||
|
<p>Unknown route.</p>
|
||||||
|
<a className="crumb" href="#/">
|
||||||
|
← back to tasks
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// REST client for the imap-copier control API.
|
||||||
|
// All requests carry the session cookie; a 401 anywhere bounces to #/login.
|
||||||
|
|
||||||
|
export type TLSMode = 'ssl' | 'starttls' | 'plain'
|
||||||
|
|
||||||
|
export interface Endpoint {
|
||||||
|
id: number
|
||||||
|
role_label: string
|
||||||
|
host: string
|
||||||
|
port: number
|
||||||
|
tls_mode: TLSMode
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Task {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
src_endpoint_id: number
|
||||||
|
dst_endpoint_id: number
|
||||||
|
status: string
|
||||||
|
folder_mapping?: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TestStatus = 'pending' | 'ok' | 'fail' | string
|
||||||
|
|
||||||
|
export interface Account {
|
||||||
|
id: number
|
||||||
|
src_login: string
|
||||||
|
dst_login: string
|
||||||
|
test_src_status: TestStatus
|
||||||
|
test_dst_status: TestStatus
|
||||||
|
status: string
|
||||||
|
copied: number
|
||||||
|
skipped: number
|
||||||
|
errors: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskDetail {
|
||||||
|
task: Task
|
||||||
|
accounts: Account[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiError extends Error {}
|
||||||
|
|
||||||
|
export async function api<T = unknown>(path: string, opts: RequestInit = {}): Promise<T> {
|
||||||
|
const res = await fetch(path, { credentials: 'include', ...opts })
|
||||||
|
if (res.status === 401) {
|
||||||
|
location.hash = '#/login'
|
||||||
|
throw new ApiError('unauthorized')
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.text()
|
||||||
|
throw new ApiError(body || res.statusText)
|
||||||
|
}
|
||||||
|
const ct = res.headers.get('content-type') || ''
|
||||||
|
if (ct.includes('application/json')) return res.json() as Promise<T>
|
||||||
|
return res.text() as unknown as T
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonBody = (body: unknown): RequestInit => ({
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const login = (user: string, pass: string) => api('/api/login', jsonBody({ user, pass }))
|
||||||
|
|
||||||
|
export const logout = () => api('/api/logout', { method: 'POST' })
|
||||||
|
|
||||||
|
export const listEndpoints = () => api<Endpoint[]>('/api/endpoints')
|
||||||
|
|
||||||
|
export const createEndpoint = (body: { role_label: string; host: string; port: number; tls_mode: TLSMode }) =>
|
||||||
|
api<{ id: number }>('/api/endpoints', jsonBody(body))
|
||||||
|
|
||||||
|
export const listTasks = () => api<Task[]>('/api/tasks')
|
||||||
|
|
||||||
|
export const getTask = (id: number) => api<TaskDetail>(`/api/tasks/${id}`)
|
||||||
|
|
||||||
|
export const createTask = (body: {
|
||||||
|
name: string
|
||||||
|
src_endpoint_id: number
|
||||||
|
dst_endpoint_id: number
|
||||||
|
folder_mapping?: Record<string, string>
|
||||||
|
}) => api<{ id: number }>('/api/tasks', jsonBody(body))
|
||||||
|
|
||||||
|
export const createAccount = (
|
||||||
|
id: number,
|
||||||
|
body: { src_login: string; src_pass: string; dst_login: string; dst_pass: string },
|
||||||
|
) => api<{ id: number }>(`/api/tasks/${id}/accounts`, jsonBody(body))
|
||||||
|
|
||||||
|
export const testAccounts = (id: number) => api(`/api/tasks/${id}/test`, { method: 'POST' })
|
||||||
|
|
||||||
|
export const runTask = (id: number) => api(`/api/tasks/${id}/run`, { method: 'POST' })
|
||||||
|
|
||||||
|
export const importCSV = (id: number, file: File) => {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('file', file)
|
||||||
|
return api<{ imported: number }>(`/api/tasks/${id}/import`, { method: 'POST', body: fd })
|
||||||
|
}
|
||||||
+568
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export function StatusBadge({ status }: { status: string }) {
|
||||||
|
const s = (status || 'pending').toLowerCase()
|
||||||
|
let cls = 'badge-pending'
|
||||||
|
if (s === 'ok' || s === 'done' || s === 'success') cls = 'badge-ok'
|
||||||
|
else if (s === 'fail' || s === 'failed' || s === 'error') cls = 'badge-fail'
|
||||||
|
else if (s === 'running' || s === 'testing' || s === 'in_progress') cls = 'badge-info'
|
||||||
|
return (
|
||||||
|
<span className={`badge ${cls}`}>
|
||||||
|
<span className="dot" />
|
||||||
|
{s}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { useEffect, useState, type FormEvent } from 'react'
|
||||||
|
import { createEndpoint, listEndpoints, type Endpoint, type TLSMode } from '../api'
|
||||||
|
|
||||||
|
const emptyForm = { role_label: '', host: '', port: '993', tls_mode: 'ssl' as TLSMode }
|
||||||
|
|
||||||
|
export function Endpoints() {
|
||||||
|
const [endpoints, setEndpoints] = useState<Endpoint[] | null>(null)
|
||||||
|
const [form, setForm] = useState(emptyForm)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
function reload() {
|
||||||
|
listEndpoints()
|
||||||
|
.then(setEndpoints)
|
||||||
|
.catch((e) => setError(String(e.message || e)))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(reload, [])
|
||||||
|
|
||||||
|
async function submit(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setBusy(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await createEndpoint({
|
||||||
|
role_label: form.role_label,
|
||||||
|
host: form.host,
|
||||||
|
port: Number(form.port),
|
||||||
|
tls_mode: form.tls_mode,
|
||||||
|
})
|
||||||
|
setForm(emptyForm)
|
||||||
|
reload()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to create endpoint')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-head">
|
||||||
|
<h1 className="page-title">
|
||||||
|
Endpoints <span className="idx">/// mailbox servers</span>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="panel-grid">
|
||||||
|
<div className="panel">
|
||||||
|
<span className="panel-label">Register endpoint</span>
|
||||||
|
<form onSubmit={submit}>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="role_label">Role label</label>
|
||||||
|
<input
|
||||||
|
id="role_label"
|
||||||
|
placeholder="e.g. src-legacy, dst-office365"
|
||||||
|
value={form.role_label}
|
||||||
|
onChange={(e) => setForm({ ...form, role_label: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="host">Host</label>
|
||||||
|
<input
|
||||||
|
id="host"
|
||||||
|
placeholder="imap.example.com"
|
||||||
|
value={form.host}
|
||||||
|
onChange={(e) => setForm({ ...form, host: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field-row">
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="port">Port</label>
|
||||||
|
<input
|
||||||
|
id="port"
|
||||||
|
type="number"
|
||||||
|
value={form.port}
|
||||||
|
onChange={(e) => setForm({ ...form, port: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="tls_mode">TLS mode</label>
|
||||||
|
<select
|
||||||
|
id="tls_mode"
|
||||||
|
value={form.tls_mode}
|
||||||
|
onChange={(e) => setForm({ ...form, tls_mode: e.target.value as TLSMode })}
|
||||||
|
>
|
||||||
|
<option value="ssl">ssl</option>
|
||||||
|
<option value="starttls">starttls</option>
|
||||||
|
<option value="plain">plain</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
<div className="btn-row">
|
||||||
|
<button className="btn btn-primary" disabled={busy}>
|
||||||
|
{busy ? 'Saving…' : 'Add endpoint'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="panel">
|
||||||
|
<span className="panel-label">Registered ({endpoints?.length ?? 0})</span>
|
||||||
|
<div className="tbl-wrap">
|
||||||
|
<table className="tbl">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>Host</th>
|
||||||
|
<th>Port</th>
|
||||||
|
<th>TLS</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{endpoints === null ? (
|
||||||
|
<tr className="empty-row">
|
||||||
|
<td colSpan={5}>loading…</td>
|
||||||
|
</tr>
|
||||||
|
) : endpoints.length === 0 ? (
|
||||||
|
<tr className="empty-row">
|
||||||
|
<td colSpan={5}>no endpoints registered yet</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
endpoints.map((ep) => (
|
||||||
|
<tr key={ep.id}>
|
||||||
|
<td className="num-cell">{ep.id}</td>
|
||||||
|
<td>{ep.role_label}</td>
|
||||||
|
<td>{ep.host}</td>
|
||||||
|
<td className="num-cell">{ep.port}</td>
|
||||||
|
<td>{ep.tls_mode}</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
import { login } from '../api'
|
||||||
|
|
||||||
|
export function Login({ onSuccess }: { onSuccess: () => void }) {
|
||||||
|
const [user, setUser] = useState('')
|
||||||
|
const [pass, setPass] = useState('')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
async function submit(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!user || !pass) return
|
||||||
|
setBusy(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await login(user, pass)
|
||||||
|
onSuccess()
|
||||||
|
} catch {
|
||||||
|
setError('Access denied — check operator id and passphrase.')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login-wrap">
|
||||||
|
<form className="login-card" onSubmit={submit}>
|
||||||
|
<h1 className="login-brand">
|
||||||
|
<span style={{ color: 'var(--accent)' }}>[</span>IMAP/COPIER<span style={{ color: 'var(--accent)' }}>]</span>
|
||||||
|
</h1>
|
||||||
|
<p className="login-sub">OPERATOR CONSOLE — AUTHENTICATE TO CONTINUE</p>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="user">Operator ID</label>
|
||||||
|
<input
|
||||||
|
id="user"
|
||||||
|
autoFocus
|
||||||
|
value={user}
|
||||||
|
onChange={(e) => setUser(e.target.value)}
|
||||||
|
autoComplete="username"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="pass">Passphrase</label>
|
||||||
|
<input
|
||||||
|
id="pass"
|
||||||
|
type="password"
|
||||||
|
value={pass}
|
||||||
|
onChange={(e) => setPass(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-primary" style={{ width: '100%' }} disabled={busy || !user || !pass}>
|
||||||
|
{busy ? 'Authenticating…' : 'Sign in'}
|
||||||
|
</button>
|
||||||
|
<div className="login-error">{error}</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
import { useEffect, useRef, useState, type ChangeEvent, type FormEvent } from 'react'
|
||||||
|
import { createAccount, getTask, importCSV, runTask, testAccounts, type TaskDetail as TaskDetailData } from '../api'
|
||||||
|
import { connectTaskWS, type TaskEvent } from '../ws'
|
||||||
|
import { StatusBadge } from '../components/StatusBadge'
|
||||||
|
|
||||||
|
const emptyAccount = { src_login: '', src_pass: '', dst_login: '', dst_pass: '' }
|
||||||
|
|
||||||
|
export function TaskDetail({ id }: { id: number }) {
|
||||||
|
const [data, setData] = useState<TaskDetailData | null>(null)
|
||||||
|
const [notFound, setNotFound] = useState(false)
|
||||||
|
const [log, setLog] = useState<{ type: string; text: string }[]>([])
|
||||||
|
const [form, setForm] = useState(emptyAccount)
|
||||||
|
const [busy, setBusy] = useState<'test' | 'run' | 'add' | 'import' | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
function reload() {
|
||||||
|
getTask(id)
|
||||||
|
.then((d) => {
|
||||||
|
setData(d)
|
||||||
|
setNotFound(false)
|
||||||
|
})
|
||||||
|
.catch(() => setNotFound(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(reload, [id])
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() =>
|
||||||
|
connectTaskWS(id, (ev: TaskEvent) => {
|
||||||
|
setLog((l) => [{ type: ev.type, text: JSON.stringify(ev.data) }, ...l].slice(0, 300))
|
||||||
|
if (['account_started', 'account_test', 'account_done', 'progress', 'run_started', 'run_done', 'error'].includes(ev.type)) {
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
[id],
|
||||||
|
)
|
||||||
|
|
||||||
|
async function submitAccount(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setBusy('add')
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await createAccount(id, form)
|
||||||
|
setForm(emptyAccount)
|
||||||
|
reload()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to add account')
|
||||||
|
} finally {
|
||||||
|
setBusy(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onFileChosen(e: ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
setBusy('import')
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await importCSV(id, file)
|
||||||
|
reload()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'CSV import failed')
|
||||||
|
} finally {
|
||||||
|
setBusy(null)
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onTest() {
|
||||||
|
setBusy('test')
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await testAccounts(id)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to start connection tests')
|
||||||
|
} finally {
|
||||||
|
setBusy(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRun() {
|
||||||
|
setBusy('run')
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await runTask(id)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to start run')
|
||||||
|
} finally {
|
||||||
|
setBusy(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notFound) {
|
||||||
|
return (
|
||||||
|
<div className="panel">
|
||||||
|
<p>Task #{id} not found.</p>
|
||||||
|
<a className="crumb" href="#/">
|
||||||
|
← back to tasks
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return <div className="muted-note">loading task #{id}…</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
const { task, accounts } = data
|
||||||
|
const allTested = accounts.length > 0 && accounts.every((a) => a.test_src_status === 'ok' && a.test_dst_status === 'ok')
|
||||||
|
const totals = accounts.reduce(
|
||||||
|
(acc, a) => ({ copied: acc.copied + a.copied, skipped: acc.skipped + a.skipped, errors: acc.errors + a.errors }),
|
||||||
|
{ copied: 0, skipped: 0, errors: 0 },
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-head">
|
||||||
|
<div>
|
||||||
|
<a className="crumb" href="#/">
|
||||||
|
← all tasks
|
||||||
|
</a>
|
||||||
|
<h1 className="page-title" style={{ marginTop: 6 }}>
|
||||||
|
{task.name} <span className="idx">/// task #{task.id}</span>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={task.status} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="panel">
|
||||||
|
<span className="panel-label">Run control</span>
|
||||||
|
<div className="stat-row">
|
||||||
|
<div className="stat ok">
|
||||||
|
<span className="val mono-num">{totals.copied}</span>
|
||||||
|
<span className="lbl">copied</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat info">
|
||||||
|
<span className="val mono-num">{totals.skipped}</span>
|
||||||
|
<span className="lbl">skipped</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat fail">
|
||||||
|
<span className="val mono-num">{totals.errors}</span>
|
||||||
|
<span className="lbl">errors</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat">
|
||||||
|
<span className="val mono-num">{accounts.length}</span>
|
||||||
|
<span className="lbl">accounts</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
<div className="btn-row" style={{ marginTop: 16 }}>
|
||||||
|
<button className="btn" onClick={onTest} disabled={busy !== null || accounts.length === 0}>
|
||||||
|
{busy === 'test' ? 'Testing…' : 'Test connections'}
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-primary" onClick={onRun} disabled={busy !== null || !allTested || task.status === 'running'}>
|
||||||
|
{busy === 'run' ? 'Starting…' : 'Run migration'}
|
||||||
|
</button>
|
||||||
|
{!allTested && accounts.length > 0 && <span className="hint">run unlocks once every account tests OK on both sides</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="panel-grid">
|
||||||
|
<div className="panel">
|
||||||
|
<span className="panel-label">Add account</span>
|
||||||
|
<form onSubmit={submitAccount}>
|
||||||
|
<div className="field-row">
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="src_login">Source login</label>
|
||||||
|
<input
|
||||||
|
id="src_login"
|
||||||
|
value={form.src_login}
|
||||||
|
onChange={(e) => setForm({ ...form, src_login: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="src_pass">Source password</label>
|
||||||
|
<input
|
||||||
|
id="src_pass"
|
||||||
|
type="password"
|
||||||
|
value={form.src_pass}
|
||||||
|
onChange={(e) => setForm({ ...form, src_pass: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="field-row">
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="dst_login">Destination login</label>
|
||||||
|
<input
|
||||||
|
id="dst_login"
|
||||||
|
value={form.dst_login}
|
||||||
|
onChange={(e) => setForm({ ...form, dst_login: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="dst_pass">Destination password</label>
|
||||||
|
<input
|
||||||
|
id="dst_pass"
|
||||||
|
type="password"
|
||||||
|
value={form.dst_pass}
|
||||||
|
onChange={(e) => setForm({ ...form, dst_pass: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="btn-row">
|
||||||
|
<button className="btn btn-primary" disabled={busy !== null}>
|
||||||
|
{busy === 'add' ? 'Adding…' : 'Add account'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="divider-label">or bulk import</div>
|
||||||
|
<div className="upload-row">
|
||||||
|
<button className="btn file-btn" disabled={busy !== null}>
|
||||||
|
{busy === 'import' ? 'Importing…' : 'Upload CSV'}
|
||||||
|
<input ref={fileInputRef} type="file" accept=".csv,text/csv" onChange={onFileChosen} disabled={busy !== null} />
|
||||||
|
</button>
|
||||||
|
<span className="hint">columns: src_login, src_pass, dst_login, dst_pass</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="panel">
|
||||||
|
<span className="panel-label">Event log</span>
|
||||||
|
<div className="log-pane">
|
||||||
|
{log.length === 0 ? (
|
||||||
|
<div className="log-empty">awaiting events over websocket…</div>
|
||||||
|
) : (
|
||||||
|
log.map((l, i) => (
|
||||||
|
<div className="log-line" key={i}>
|
||||||
|
<span className="tag">{l.type}</span>
|
||||||
|
<span className="payload">{l.text}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="panel">
|
||||||
|
<span className="panel-label">Accounts ({accounts.length})</span>
|
||||||
|
<div className="tbl-wrap">
|
||||||
|
<table className="tbl">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Source</th>
|
||||||
|
<th>Destination</th>
|
||||||
|
<th>Src test</th>
|
||||||
|
<th>Dst test</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Copied</th>
|
||||||
|
<th>Skipped</th>
|
||||||
|
<th>Errors</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{accounts.length === 0 ? (
|
||||||
|
<tr className="empty-row">
|
||||||
|
<td colSpan={8}>no accounts yet — add one or import a CSV above</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
accounts.map((a) => (
|
||||||
|
<tr key={a.id}>
|
||||||
|
<td>{a.src_login}</td>
|
||||||
|
<td>{a.dst_login}</td>
|
||||||
|
<td>
|
||||||
|
<StatusBadge status={a.test_src_status} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<StatusBadge status={a.test_dst_status} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<StatusBadge status={a.status} />
|
||||||
|
</td>
|
||||||
|
<td className="num-cell">{a.copied}</td>
|
||||||
|
<td className="num-cell">{a.skipped}</td>
|
||||||
|
<td className="num-cell">{a.errors}</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { useEffect, useState, type FormEvent } from 'react'
|
||||||
|
import { createTask, listEndpoints, listTasks, type Endpoint, type Task } from '../api'
|
||||||
|
import { StatusBadge } from '../components/StatusBadge'
|
||||||
|
|
||||||
|
export function Tasks() {
|
||||||
|
const [tasks, setTasks] = useState<Task[] | null>(null)
|
||||||
|
const [endpoints, setEndpoints] = useState<Endpoint[]>([])
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [srcId, setSrcId] = useState('')
|
||||||
|
const [dstId, setDstId] = useState('')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
function reload() {
|
||||||
|
listTasks()
|
||||||
|
.then(setTasks)
|
||||||
|
.catch((e: unknown) => setError(e instanceof Error ? e.message : 'Failed to load tasks'))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reload()
|
||||||
|
listEndpoints().then(setEndpoints).catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function submit(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setBusy(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await createTask({
|
||||||
|
name,
|
||||||
|
src_endpoint_id: Number(srcId),
|
||||||
|
dst_endpoint_id: Number(dstId),
|
||||||
|
})
|
||||||
|
setName('')
|
||||||
|
setSrcId('')
|
||||||
|
setDstId('')
|
||||||
|
reload()
|
||||||
|
location.hash = `#/tasks/${res.id}`
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to create task')
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSubmit = name.trim() !== '' && srcId !== '' && dstId !== '' && srcId !== dstId
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-head">
|
||||||
|
<h1 className="page-title">
|
||||||
|
Migration tasks <span className="idx">/// mailbox copy jobs</span>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="panel">
|
||||||
|
<span className="panel-label">New task</span>
|
||||||
|
{endpoints.length < 2 ? (
|
||||||
|
<p className="muted-note">
|
||||||
|
Register at least two endpoints (source & destination) on the{' '}
|
||||||
|
<a className="crumb" href="#/endpoints">
|
||||||
|
Endpoints
|
||||||
|
</a>{' '}
|
||||||
|
screen before creating a task.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={submit}>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="name">Task name</label>
|
||||||
|
<input id="name" value={name} onChange={(e) => setName(e.target.value)} placeholder="q3-office365-migration" required />
|
||||||
|
</div>
|
||||||
|
<div className="field-row">
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="src">Source endpoint</label>
|
||||||
|
<select id="src" value={srcId} onChange={(e) => setSrcId(e.target.value)} required>
|
||||||
|
<option value="" disabled>
|
||||||
|
select…
|
||||||
|
</option>
|
||||||
|
{endpoints.map((ep) => (
|
||||||
|
<option key={ep.id} value={ep.id}>
|
||||||
|
{ep.role_label} — {ep.host}:{ep.port}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label htmlFor="dst">Destination endpoint</label>
|
||||||
|
<select id="dst" value={dstId} onChange={(e) => setDstId(e.target.value)} required>
|
||||||
|
<option value="" disabled>
|
||||||
|
select…
|
||||||
|
</option>
|
||||||
|
{endpoints.map((ep) => (
|
||||||
|
<option key={ep.id} value={ep.id}>
|
||||||
|
{ep.role_label} — {ep.host}:{ep.port}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
<div className="btn-row">
|
||||||
|
<button className="btn btn-primary" disabled={busy || !canSubmit}>
|
||||||
|
{busy ? 'Creating…' : 'Create task'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="panel">
|
||||||
|
<span className="panel-label">All tasks ({tasks?.length ?? 0})</span>
|
||||||
|
<div className="tbl-wrap">
|
||||||
|
<table className="tbl">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Route</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{tasks === null ? (
|
||||||
|
<tr className="empty-row">
|
||||||
|
<td colSpan={4}>loading…</td>
|
||||||
|
</tr>
|
||||||
|
) : tasks.length === 0 ? (
|
||||||
|
<tr className="empty-row">
|
||||||
|
<td colSpan={4}>no tasks yet — create one above</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
tasks.map((t) => (
|
||||||
|
<tr key={t.id}>
|
||||||
|
<td className="num-cell">{t.id}</td>
|
||||||
|
<td>
|
||||||
|
<a className="rowlink" href={`#/tasks/${t.id}`}>
|
||||||
|
{t.name}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
#{t.src_endpoint_id} → #{t.dst_endpoint_id}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<StatusBadge status={t.status} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./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"]
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user