From 0d42eb2db01ade9342716ac34d5d60c7a77445fb Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 1 Jul 2026 16:27:31 +0700 Subject: [PATCH 01/30] feat(config): env-based configuration with validation --- go.mod | 3 ++ internal/config/config.go | 60 ++++++++++++++++++++++++++++++++++ internal/config/config_test.go | 37 +++++++++++++++++++++ 3 files changed, 100 insertions(+) create mode 100644 go.mod create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..760c62b --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/vasyansk/imap-copier + +go 1.26.4 diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..cb91069 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,60 @@ +package config + +import ( + "encoding/base64" + "fmt" + "os" + "strconv" +) + +type Config struct { + HTTPAddr string + DatabaseURL string + AuthUser string + AuthPass string + EncKey []byte + SessionSecret []byte + WorkerConcurrency int +} + +func Load() (Config, error) { + c := Config{ + HTTPAddr: getenv("HTTP_ADDR", ":8080"), + DatabaseURL: os.Getenv("DATABASE_URL"), + AuthUser: os.Getenv("AUTH_USER"), + AuthPass: os.Getenv("AUTH_PASS"), + SessionSecret: []byte(os.Getenv("SESSION_SECRET")), + WorkerConcurrency: 4, + } + if v := os.Getenv("WORKER_CONCURRENCY"); v != "" { + n, err := strconv.Atoi(v) + if err != nil || n < 1 { + return Config{}, fmt.Errorf("WORKER_CONCURRENCY invalid: %q", v) + } + c.WorkerConcurrency = n + } + for k, v := range map[string]string{ + "DATABASE_URL": c.DatabaseURL, "AUTH_USER": c.AuthUser, + "AUTH_PASS": c.AuthPass, "SESSION_SECRET": string(c.SessionSecret), + } { + if v == "" { + return Config{}, fmt.Errorf("%s is required", k) + } + } + key, err := base64.StdEncoding.DecodeString(os.Getenv("ENC_KEY")) + if err != nil { + return Config{}, fmt.Errorf("ENC_KEY must be base64: %w", err) + } + if len(key) != 32 { + return Config{}, fmt.Errorf("ENC_KEY must decode to 32 bytes, got %d", len(key)) + } + c.EncKey = key + return c, nil +} + +func getenv(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..0c1945b --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,37 @@ +package config + +import ( + "encoding/base64" + "testing" +) + +func TestLoadRequiresEncKey32Bytes(t *testing.T) { + t.Setenv("DATABASE_URL", "postgres://x") + t.Setenv("AUTH_USER", "admin") + t.Setenv("AUTH_PASS", "pass") + t.Setenv("SESSION_SECRET", "secret") + t.Setenv("ENC_KEY", base64.StdEncoding.EncodeToString(make([]byte, 16))) // wrong size + + if _, err := Load(); err == nil { + t.Fatal("expected error for 16-byte ENC_KEY, got nil") + } +} + +func TestLoadDefaults(t *testing.T) { + t.Setenv("DATABASE_URL", "postgres://x") + t.Setenv("AUTH_USER", "admin") + t.Setenv("AUTH_PASS", "pass") + t.Setenv("SESSION_SECRET", "secret") + t.Setenv("ENC_KEY", base64.StdEncoding.EncodeToString(make([]byte, 32))) + + cfg, err := Load() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.HTTPAddr != ":8080" { + t.Errorf("HTTPAddr = %q, want :8080", cfg.HTTPAddr) + } + if cfg.WorkerConcurrency != 4 { + t.Errorf("WorkerConcurrency = %d, want 4", cfg.WorkerConcurrency) + } +} From 0dfe39565e3c2a6b5b99ba9d2b4d02220090149c Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 1 Jul 2026 16:31:11 +0700 Subject: [PATCH 02/30] fix(config): pin go directive to 1.22 for build portability --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 760c62b..f4dd485 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/vasyansk/imap-copier -go 1.26.4 +go 1.22 From 8d4a605b9f9945a92c780a2fc2d4e83c2f4090bc Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 1 Jul 2026 16:33:13 +0700 Subject: [PATCH 03/30] feat(crypto): AES-256-GCM password encryption --- internal/crypto/crypto.go | 47 ++++++++++++++++++++++++++++++++++ internal/crypto/crypto_test.go | 33 ++++++++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 internal/crypto/crypto.go create mode 100644 internal/crypto/crypto_test.go diff --git a/internal/crypto/crypto.go b/internal/crypto/crypto.go new file mode 100644 index 0000000..789bbe8 --- /dev/null +++ b/internal/crypto/crypto.go @@ -0,0 +1,47 @@ +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) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + return cipher.NewGCM(block) +} diff --git a/internal/crypto/crypto_test.go b/internal/crypto/crypto_test.go new file mode 100644 index 0000000..f94a6e4 --- /dev/null +++ b/internal/crypto/crypto_test.go @@ -0,0 +1,33 @@ +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)") + } +} From 06d601482cf5ea49c84c29a1c081e29169d04ece Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 1 Jul 2026 16:36:19 +0700 Subject: [PATCH 04/30] fix(crypto): enforce 32-byte key for AES-256 --- internal/crypto/crypto.go | 3 +++ internal/crypto/crypto_test.go | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/internal/crypto/crypto.go b/internal/crypto/crypto.go index 789bbe8..0adf923 100644 --- a/internal/crypto/crypto.go +++ b/internal/crypto/crypto.go @@ -39,6 +39,9 @@ func Decrypt(key []byte, enc string) ([]byte, error) { } 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 diff --git a/internal/crypto/crypto_test.go b/internal/crypto/crypto_test.go index f94a6e4..ca95e75 100644 --- a/internal/crypto/crypto_test.go +++ b/internal/crypto/crypto_test.go @@ -31,3 +31,9 @@ func TestEncryptNonDeterministic(t *testing.T) { 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)") + } +} From 0b9d31bd15e9971ba309accbc2d5802015be7c06 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 1 Jul 2026 16:37:56 +0700 Subject: [PATCH 05/30] feat(crypto): HMAC signed session tokens --- internal/crypto/session.go | 44 +++++++++++++++++++++++++++++++++ internal/crypto/session_test.go | 34 +++++++++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 internal/crypto/session.go create mode 100644 internal/crypto/session_test.go diff --git a/internal/crypto/session.go b/internal/crypto/session.go new file mode 100644 index 0000000..2c938d0 --- /dev/null +++ b/internal/crypto/session.go @@ -0,0 +1,44 @@ +package crypto + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "fmt" + "strconv" + "strings" + "time" +) + +// token = base64(user) "." expiryUnix "." base64(hmac) +func SignSession(secret []byte, user string, expiry time.Time) string { + payload := base64.RawURLEncoding.EncodeToString([]byte(user)) + "." + + strconv.FormatInt(expiry.Unix(), 10) + return payload + "." + sign(secret, payload) +} + +func VerifySession(secret []byte, token string, now time.Time) (string, bool) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return "", false + } + payload := parts[0] + "." + parts[1] + if !hmac.Equal([]byte(parts[2]), []byte(sign(secret, payload))) { + return "", false + } + exp, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil || now.Unix() > exp { + return "", false + } + user, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return "", false + } + return string(user), true +} + +func sign(secret []byte, payload string) string { + m := hmac.New(sha256.New, secret) + fmt.Fprint(m, payload) + return base64.RawURLEncoding.EncodeToString(m.Sum(nil)) +} diff --git a/internal/crypto/session_test.go b/internal/crypto/session_test.go new file mode 100644 index 0000000..8d844f9 --- /dev/null +++ b/internal/crypto/session_test.go @@ -0,0 +1,34 @@ +package crypto + +import ( + "testing" + "time" +) + +func TestSessionRoundTrip(t *testing.T) { + secret := []byte("s3cr3t") + now := time.Unix(1_700_000_000, 0) + tok := SignSession(secret, "admin", now.Add(time.Hour)) + user, ok := VerifySession(secret, tok, now) + if !ok || user != "admin" { + t.Fatalf("verify = %q,%v want admin,true", user, ok) + } +} + +func TestSessionRejectsExpired(t *testing.T) { + secret := []byte("s3cr3t") + now := time.Unix(1_700_000_000, 0) + tok := SignSession(secret, "admin", now.Add(-time.Second)) + if _, ok := VerifySession(secret, tok, now); ok { + t.Fatal("expired token must be rejected") + } +} + +func TestSessionRejectsTampered(t *testing.T) { + secret := []byte("s3cr3t") + now := time.Unix(1_700_000_000, 0) + tok := SignSession(secret, "admin", now.Add(time.Hour)) + if _, ok := VerifySession([]byte("other"), tok, now); ok { + t.Fatal("wrong secret must be rejected") + } +} From edda3dc21f68ce9ff84f2ff2fb6dd7c57a37517d Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 1 Jul 2026 16:42:39 +0700 Subject: [PATCH 06/30] feat(db): initial schema migration --- migrations/0001_init.down.sql | 5 ++++ migrations/0001_init.up.sql | 53 +++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 migrations/0001_init.down.sql create mode 100644 migrations/0001_init.up.sql diff --git a/migrations/0001_init.down.sql b/migrations/0001_init.down.sql new file mode 100644 index 0000000..eb0e5c3 --- /dev/null +++ b/migrations/0001_init.down.sql @@ -0,0 +1,5 @@ +DROP TABLE IF EXISTS migrated_messages; +DROP TABLE IF EXISTS runs; +DROP TABLE IF EXISTS accounts; +DROP TABLE IF EXISTS tasks; +DROP TABLE IF EXISTS endpoints; diff --git a/migrations/0001_init.up.sql b/migrations/0001_init.up.sql new file mode 100644 index 0000000..461d558 --- /dev/null +++ b/migrations/0001_init.up.sql @@ -0,0 +1,53 @@ +CREATE TABLE endpoints ( + id BIGSERIAL PRIMARY KEY, + role_label TEXT NOT NULL, + host TEXT NOT NULL, + port INT NOT NULL, + tls_mode TEXT NOT NULL CHECK (tls_mode IN ('ssl','starttls','plain')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE tasks ( + id BIGSERIAL PRIMARY KEY, + name TEXT NOT NULL, + src_endpoint_id BIGINT NOT NULL REFERENCES endpoints(id), + dst_endpoint_id BIGINT NOT NULL REFERENCES endpoints(id), + status TEXT NOT NULL DEFAULT 'draft', + folder_mapping JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE accounts ( + id BIGSERIAL PRIMARY KEY, + task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + src_login TEXT NOT NULL, + src_pass_enc TEXT NOT NULL, + dst_login TEXT NOT NULL, + dst_pass_enc TEXT NOT NULL, + test_src_status TEXT NOT NULL DEFAULT 'unknown', + test_dst_status TEXT NOT NULL DEFAULT 'unknown', + copied_count BIGINT NOT NULL DEFAULT 0, + skipped_count BIGINT NOT NULL DEFAULT 0, + error_count BIGINT NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'idle' +); + +CREATE TABLE runs ( + id BIGSERIAL PRIMARY KEY, + task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + finished_at TIMESTAMPTZ, + status TEXT NOT NULL DEFAULT 'running', + total_copied BIGINT NOT NULL DEFAULT 0, + total_skipped BIGINT NOT NULL DEFAULT 0, + total_errors BIGINT NOT NULL DEFAULT 0 +); + +CREATE TABLE migrated_messages ( + id BIGSERIAL PRIMARY KEY, + account_id BIGINT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + folder TEXT NOT NULL, + message_key TEXT NOT NULL, + copied_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (account_id, message_key) +); From 420358b55824d7f0f307c1e1660b50647623bac6 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 1 Jul 2026 16:50:45 +0700 Subject: [PATCH 07/30] feat(store): pgx pool and endpoints CRUD --- go.mod | 14 ++++++++++- go.sum | 28 ++++++++++++++++++++++ internal/store/endpoints.go | 46 ++++++++++++++++++++++++++++++++++++ internal/store/store.go | 22 +++++++++++++++++ internal/store/store_test.go | 40 +++++++++++++++++++++++++++++++ 5 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 go.sum create mode 100644 internal/store/endpoints.go create mode 100644 internal/store/store.go create mode 100644 internal/store/store_test.go diff --git a/go.mod b/go.mod index f4dd485..a73bd7a 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,15 @@ module github.com/vasyansk/imap-copier -go 1.22 +go 1.22.0 + +require github.com/jackc/pgx/v5 v5.7.0 + +require ( + 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/stretchr/testify v1.9.0 // indirect + golang.org/x/crypto v0.31.0 // indirect + golang.org/x/sync v0.10.0 // indirect + golang.org/x/text v0.21.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7bacfd8 --- /dev/null +++ b/go.sum @@ -0,0 +1,28 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/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.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/store/endpoints.go b/internal/store/endpoints.go new file mode 100644 index 0000000..e43a418 --- /dev/null +++ b/internal/store/endpoints.go @@ -0,0 +1,46 @@ +package store + +import "context" + +type Endpoint struct { + ID int64 + RoleLabel string + Host string + Port int + TLSMode string +} + +func (s *Store) CreateEndpoint(ctx context.Context, e Endpoint) (int64, error) { + var id int64 + err := s.Pool.QueryRow(ctx, + `INSERT INTO endpoints (role_label, host, port, tls_mode) + VALUES ($1,$2,$3,$4) RETURNING id`, + e.RoleLabel, e.Host, e.Port, e.TLSMode).Scan(&id) + return id, err +} + +func (s *Store) GetEndpoint(ctx context.Context, id int64) (Endpoint, error) { + var e Endpoint + err := s.Pool.QueryRow(ctx, + `SELECT id, role_label, host, port, tls_mode FROM endpoints WHERE id=$1`, id). + Scan(&e.ID, &e.RoleLabel, &e.Host, &e.Port, &e.TLSMode) + return e, err +} + +func (s *Store) ListEndpoints(ctx context.Context) ([]Endpoint, error) { + rows, err := s.Pool.Query(ctx, + `SELECT id, role_label, host, port, tls_mode FROM endpoints ORDER BY id`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Endpoint + for rows.Next() { + var e Endpoint + if err := rows.Scan(&e.ID, &e.RoleLabel, &e.Host, &e.Port, &e.TLSMode); err != nil { + return nil, err + } + out = append(out, e) + } + return out, rows.Err() +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..da10279 --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,22 @@ +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 { + return nil, err + } + return &Store{Pool: pool}, nil +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..ef9d556 --- /dev/null +++ b/internal/store/store_test.go @@ -0,0 +1,40 @@ +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) + } +} From 0cf9de38c465b7332281e7d43311e161f7b0743e Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 1 Jul 2026 16:55:24 +0700 Subject: [PATCH 08/30] fix(store): close pool on failed Ping; add ListEndpoints test --- internal/store/store.go | 1 + internal/store/store_test.go | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/internal/store/store.go b/internal/store/store.go index da10279..70a6f09 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -16,6 +16,7 @@ func New(ctx context.Context, dsn string) (*Store, error) { return nil, err } if err := pool.Ping(ctx); err != nil { + pool.Close() return nil, err } return &Store{Pool: pool}, nil diff --git a/internal/store/store_test.go b/internal/store/store_test.go index ef9d556..2a12c33 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -38,3 +38,23 @@ func TestCreateAndGetEndpoint(t *testing.T) { 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) + } +} From 67a2367baa5d80999643e7dc633e64a850bf10a5 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 1 Jul 2026 16:58:33 +0700 Subject: [PATCH 09/30] feat(store): tasks, accounts, runs, dedup journal --- internal/store/accounts.go | 74 +++++++++++++++++++++++++++++++++ internal/store/accounts_test.go | 30 +++++++++++++ internal/store/migrated.go | 30 +++++++++++++ internal/store/runs.go | 27 ++++++++++++ internal/store/tasks.go | 57 +++++++++++++++++++++++++ 5 files changed, 218 insertions(+) create mode 100644 internal/store/accounts.go create mode 100644 internal/store/accounts_test.go create mode 100644 internal/store/migrated.go create mode 100644 internal/store/runs.go create mode 100644 internal/store/tasks.go diff --git a/internal/store/accounts.go b/internal/store/accounts.go new file mode 100644 index 0000000..80041d6 --- /dev/null +++ b/internal/store/accounts.go @@ -0,0 +1,74 @@ +package store + +import ( + "context" + "fmt" +) + +type Account struct { + ID int64 + TaskID int64 + SrcLogin string + SrcPassEnc string + DstLogin string + DstPassEnc string + TestSrcStatus string + TestDstStatus string + Status string + Copied int64 + Skipped int64 + Errors int64 +} + +func (s *Store) CreateAccount(ctx context.Context, a Account) (int64, error) { + var id int64 + err := s.Pool.QueryRow(ctx, + `INSERT INTO accounts (task_id, src_login, src_pass_enc, dst_login, dst_pass_enc) + VALUES ($1,$2,$3,$4,$5) RETURNING id`, + a.TaskID, a.SrcLogin, a.SrcPassEnc, a.DstLogin, a.DstPassEnc).Scan(&id) + return id, err +} + +func (s *Store) ListAccountsByTask(ctx context.Context, taskID int64) ([]Account, error) { + rows, err := s.Pool.Query(ctx, + `SELECT id, task_id, src_login, src_pass_enc, dst_login, dst_pass_enc, + test_src_status, test_dst_status, status, copied_count, skipped_count, error_count + FROM accounts WHERE task_id=$1 ORDER BY id`, taskID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Account + for rows.Next() { + var a Account + if err := rows.Scan(&a.ID, &a.TaskID, &a.SrcLogin, &a.SrcPassEnc, &a.DstLogin, &a.DstPassEnc, + &a.TestSrcStatus, &a.TestDstStatus, &a.Status, &a.Copied, &a.Skipped, &a.Errors); err != nil { + return nil, err + } + out = append(out, a) + } + return out, rows.Err() +} + +// side = "src" | "dst" +func (s *Store) SetAccountTestStatus(ctx context.Context, id int64, side, status string) error { + col := "test_src_status" + if side == "dst" { + col = "test_dst_status" + } + _, err := s.Pool.Exec(ctx, fmt.Sprintf(`UPDATE accounts SET %s=$2 WHERE id=$1`, col), id, status) + return err +} + +func (s *Store) SetAccountStatus(ctx context.Context, id int64, status string) error { + _, err := s.Pool.Exec(ctx, `UPDATE accounts SET status=$2 WHERE id=$1`, id, status) + return err +} + +func (s *Store) IncAccountCounters(ctx context.Context, id, copied, skipped, errs int64) error { + _, err := s.Pool.Exec(ctx, + `UPDATE accounts SET copied_count=copied_count+$2, + skipped_count=skipped_count+$3, error_count=error_count+$4 WHERE id=$1`, + id, copied, skipped, errs) + return err +} diff --git a/internal/store/accounts_test.go b/internal/store/accounts_test.go new file mode 100644 index 0000000..07b4fa9 --- /dev/null +++ b/internal/store/accounts_test.go @@ -0,0 +1,30 @@ +package store + +import ( + "context" + "testing" +) + +func TestMigratedIdempotency(t *testing.T) { + s := testStore(t) + ctx := context.Background() + epSrc, _ := s.CreateEndpoint(ctx, Endpoint{RoleLabel: "src", Host: "a", Port: 993, TLSMode: "ssl"}) + epDst, _ := s.CreateEndpoint(ctx, Endpoint{RoleLabel: "dst", Host: "b", Port: 993, TLSMode: "ssl"}) + taskID, _ := s.CreateTask(ctx, Task{Name: "t", SrcEndpointID: epSrc, DstEndpointID: epDst}) + accID, _ := s.CreateAccount(ctx, Account{TaskID: taskID, SrcLogin: "u", SrcPassEnc: "x", DstLogin: "u2", DstPassEnc: "y"}) + + if err := s.MarkMigrated(ctx, accID, "INBOX", ""); err != nil { + t.Fatalf("mark: %v", err) + } + if err := s.MarkMigrated(ctx, accID, "INBOX", ""); err != nil { + t.Fatalf("second mark must not error (ON CONFLICT): %v", err) + } + ok, err := s.IsMigrated(ctx, accID, "") + if err != nil || !ok { + t.Fatalf("IsMigrated = %v,%v want true,nil", ok, err) + } + absent, _ := s.IsMigrated(ctx, accID, "") + if absent { + t.Fatal("unknown key must be false") + } +} diff --git a/internal/store/migrated.go b/internal/store/migrated.go new file mode 100644 index 0000000..5fb1d49 --- /dev/null +++ b/internal/store/migrated.go @@ -0,0 +1,30 @@ +package store + +import ( + "context" + "errors" + + "github.com/jackc/pgx/v5" +) + +func (s *Store) IsMigrated(ctx context.Context, accountID int64, key string) (bool, error) { + var one int + err := s.Pool.QueryRow(ctx, + `SELECT 1 FROM migrated_messages WHERE account_id=$1 AND message_key=$2`, + accountID, key).Scan(&one) + if errors.Is(err, pgx.ErrNoRows) { + return false, nil + } + if err != nil { + return false, err + } + return true, nil +} + +func (s *Store) MarkMigrated(ctx context.Context, accountID int64, folder, key string) error { + _, err := s.Pool.Exec(ctx, + `INSERT INTO migrated_messages (account_id, folder, message_key) + VALUES ($1,$2,$3) ON CONFLICT (account_id, message_key) DO NOTHING`, + accountID, folder, key) + return err +} diff --git a/internal/store/runs.go b/internal/store/runs.go new file mode 100644 index 0000000..ac0075a --- /dev/null +++ b/internal/store/runs.go @@ -0,0 +1,27 @@ +package store + +import "context" + +type Run struct { + ID int64 + TaskID int64 + Status string + TotalCopied int64 + TotalSkipped int64 + TotalErrors int64 +} + +func (s *Store) CreateRun(ctx context.Context, taskID int64) (int64, error) { + var id int64 + err := s.Pool.QueryRow(ctx, + `INSERT INTO runs (task_id) VALUES ($1) RETURNING id`, taskID).Scan(&id) + return id, err +} + +func (s *Store) FinishRun(ctx context.Context, id int64, status string, copied, skipped, errs int64) error { + _, err := s.Pool.Exec(ctx, + `UPDATE runs SET status=$2, finished_at=now(), + total_copied=$3, total_skipped=$4, total_errors=$5 WHERE id=$1`, + id, status, copied, skipped, errs) + return err +} diff --git a/internal/store/tasks.go b/internal/store/tasks.go new file mode 100644 index 0000000..2d48fea --- /dev/null +++ b/internal/store/tasks.go @@ -0,0 +1,57 @@ +package store + +import "context" + +type Task struct { + ID int64 + Name string + SrcEndpointID int64 + DstEndpointID int64 + Status string + FolderMapping map[string]string +} + +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 +} From 37cb8ba076e901035777cb838d392fd5ae895d1a Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 1 Jul 2026 17:03:18 +0700 Subject: [PATCH 10/30] feat(imapx): message dedup key (Message-ID + header fallback) --- go.mod | 5 ++++- go.sum | 2 ++ internal/imapx/messagekey.go | 37 +++++++++++++++++++++++++++++++ internal/imapx/messagekey_test.go | 32 ++++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 internal/imapx/messagekey.go create mode 100644 internal/imapx/messagekey_test.go diff --git a/go.mod b/go.mod index a73bd7a..112d117 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,10 @@ module github.com/vasyansk/imap-copier go 1.22.0 -require github.com/jackc/pgx/v5 v5.7.0 +require ( + github.com/emersion/go-imap/v2 v2.0.0-beta.8 + github.com/jackc/pgx/v5 v5.7.0 +) require ( github.com/jackc/pgpassfile v1.0.0 // indirect diff --git a/go.sum b/go.sum index 7bacfd8..924a0ce 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/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= diff --git a/internal/imapx/messagekey.go b/internal/imapx/messagekey.go new file mode 100644 index 0000000..09a50fe --- /dev/null +++ b/internal/imapx/messagekey.go @@ -0,0 +1,37 @@ +package imapx + +import ( + "crypto/md5" + "fmt" + "strings" + + "github.com/emersion/go-imap/v2" +) + +func MessageKey(env *imap.Envelope, size int64) string { + if env != nil && env.MessageID != "" { + return env.MessageID + } + var b strings.Builder + if env != nil { + b.WriteString(addrList(env.From)) + b.WriteByte('|') + b.WriteString(addrList(env.To)) + b.WriteByte('|') + b.WriteString(env.Subject) + b.WriteByte('|') + b.WriteString(env.Date.UTC().Format("2006-01-02T15:04:05Z")) + } + b.WriteByte('|') + fmt.Fprintf(&b, "%d", size) + sum := md5.Sum([]byte(b.String())) + return fmt.Sprintf("h:%x", sum) +} + +func addrList(addrs []imap.Address) string { + parts := make([]string, 0, len(addrs)) + for _, a := range addrs { + parts = append(parts, a.Mailbox+"@"+a.Host) + } + return strings.Join(parts, ",") +} diff --git a/internal/imapx/messagekey_test.go b/internal/imapx/messagekey_test.go new file mode 100644 index 0000000..8d46c57 --- /dev/null +++ b/internal/imapx/messagekey_test.go @@ -0,0 +1,32 @@ +package imapx + +import ( + "testing" + "time" + + "github.com/emersion/go-imap/v2" +) + +func TestMessageKeyPrefersMessageID(t *testing.T) { + env := &imap.Envelope{MessageID: ""} + if got := MessageKey(env, 100); got != "" { + t.Fatalf("got %q, want ", got) + } +} + +func TestMessageKeyFallbackStable(t *testing.T) { + env := &imap.Envelope{ + Subject: "Hi", + Date: time.Unix(1700000000, 0).UTC(), + From: []imap.Address{{Mailbox: "a", Host: "x.com"}}, + To: []imap.Address{{Mailbox: "b", Host: "y.com"}}, + } + k1 := MessageKey(env, 42) + k2 := MessageKey(env, 42) + if k1 != k2 { + t.Fatal("fallback key must be deterministic") + } + if MessageKey(env, 43) == k1 { + t.Fatal("different size must change key") + } +} From 0451b79c645678f3f6e71c4da302e880019d983e Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 1 Jul 2026 17:20:03 +0700 Subject: [PATCH 11/30] feat(imapx): connect, endpoint test, login test with folder listing --- go.mod | 2 ++ go.sum | 35 ++++++++++++++++++++++++++ internal/imapx/account.go | 25 +++++++++++++++++++ internal/imapx/dial.go | 50 +++++++++++++++++++++++++++++++++++++ internal/imapx/dial_test.go | 42 +++++++++++++++++++++++++++++++ 5 files changed, 154 insertions(+) create mode 100644 internal/imapx/account.go create mode 100644 internal/imapx/dial.go create mode 100644 internal/imapx/dial_test.go diff --git a/go.mod b/go.mod index 112d117..a132be3 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,8 @@ require ( ) 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 diff --git a/go.sum b/go.sum index 924a0ce..e90854c 100644 --- a/go.sum +++ b/go.sum @@ -3,6 +3,10 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/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= @@ -18,12 +22,43 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +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.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +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.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +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/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.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +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= diff --git a/internal/imapx/account.go b/internal/imapx/account.go new file mode 100644 index 0000000..fc735d3 --- /dev/null +++ b/internal/imapx/account.go @@ -0,0 +1,25 @@ +package imapx + +import ( + "context" +) + +func TestLogin(ctx context.Context, ep Endpoint, login, pass string) ([]string, error) { + c, err := Connect(ctx, ep) + if err != nil { + return nil, err + } + defer func() { _ = c.Logout().Wait() }() + if err := c.Login(login, pass).Wait(); err != nil { + return nil, err + } + mboxes, err := c.List("", "*", nil).Collect() + if err != nil { + return nil, err + } + names := make([]string, 0, len(mboxes)) + for _, m := range mboxes { + names = append(names, m.Mailbox) + } + return names, nil +} diff --git a/internal/imapx/dial.go b/internal/imapx/dial.go new file mode 100644 index 0000000..a23f114 --- /dev/null +++ b/internal/imapx/dial.go @@ -0,0 +1,50 @@ +package imapx + +import ( + "context" + "crypto/tls" + "fmt" + + "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 Connect(ctx context.Context, ep Endpoint) (*imapclient.Client, error) { + var ( + c *imapclient.Client + err error + ) + switch ep.TLSMode { + case "ssl": + c, err = imapclient.DialTLS(ep.addr(), &imapclient.Options{ + TLSConfig: &tls.Config{ServerName: ep.Host}, + }) + case "starttls": + c, err = imapclient.DialStartTLS(ep.addr(), &imapclient.Options{ + TLSConfig: &tls.Config{ServerName: ep.Host}, + }) + case "plain": + c, err = imapclient.DialInsecure(ep.addr(), nil) + default: + return nil, fmt.Errorf("unknown tls_mode %q", ep.TLSMode) + } + if err != nil { + return nil, err + } + return c, nil +} + +func TestEndpoint(ctx context.Context, ep Endpoint) error { + c, err := Connect(ctx, ep) + if err != nil { + return err + } + return c.Logout().Wait() +} diff --git a/internal/imapx/dial_test.go b/internal/imapx/dial_test.go new file mode 100644 index 0000000..6f621aa --- /dev/null +++ b/internal/imapx/dial_test.go @@ -0,0 +1,42 @@ +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) + } +} From 79f5c5b93a2138ff3af3f82594606b7cbd8f3edd Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 1 Jul 2026 17:28:10 +0700 Subject: [PATCH 12/30] fix(imapx): honor context in Connect with bounded dial retry --- internal/imapx/dial.go | 39 ++++++++++++++++++++++++++----------- internal/imapx/dial_test.go | 9 +++++++++ 2 files changed, 37 insertions(+), 11 deletions(-) diff --git a/internal/imapx/dial.go b/internal/imapx/dial.go index a23f114..8de1b20 100644 --- a/internal/imapx/dial.go +++ b/internal/imapx/dial.go @@ -4,6 +4,7 @@ import ( "context" "crypto/tls" "fmt" + "time" "github.com/emersion/go-imap/v2/imapclient" ) @@ -16,29 +17,45 @@ type Endpoint struct { func (e Endpoint) addr() string { return fmt.Sprintf("%s:%d", e.Host, e.Port) } -func Connect(ctx context.Context, ep Endpoint) (*imapclient.Client, error) { - var ( - c *imapclient.Client - err error - ) +func dialOnce(ep Endpoint) (*imapclient.Client, error) { switch ep.TLSMode { case "ssl": - c, err = imapclient.DialTLS(ep.addr(), &imapclient.Options{ + return imapclient.DialTLS(ep.addr(), &imapclient.Options{ TLSConfig: &tls.Config{ServerName: ep.Host}, }) case "starttls": - c, err = imapclient.DialStartTLS(ep.addr(), &imapclient.Options{ + return imapclient.DialStartTLS(ep.addr(), &imapclient.Options{ TLSConfig: &tls.Config{ServerName: ep.Host}, }) case "plain": - c, err = imapclient.DialInsecure(ep.addr(), nil) + return imapclient.DialInsecure(ep.addr(), nil) default: return nil, fmt.Errorf("unknown tls_mode %q", ep.TLSMode) } - if err != nil { - return nil, err +} + +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 c, nil + return nil, lastErr } func TestEndpoint(ctx context.Context, ep Endpoint) error { diff --git a/internal/imapx/dial_test.go b/internal/imapx/dial_test.go index 6f621aa..f51a006 100644 --- a/internal/imapx/dial_test.go +++ b/internal/imapx/dial_test.go @@ -40,3 +40,12 @@ func TestTestLoginListsFolders(t *testing.T) { 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") + } +} From 06c0598b805c4617c5de13e1b0433eaca40687a9 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 1 Jul 2026 17:39:43 +0700 Subject: [PATCH 13/30] feat(imapx): streaming per-folder copy with dedup, idempotent --- internal/imapx/copy.go | 152 ++++++++++++++++++++++++++++++++++++ internal/imapx/copy_test.go | 92 ++++++++++++++++++++++ 2 files changed, 244 insertions(+) create mode 100644 internal/imapx/copy.go create mode 100644 internal/imapx/copy_test.go diff --git a/internal/imapx/copy.go b/internal/imapx/copy.go new file mode 100644 index 0000000..c43a4bb --- /dev/null +++ b/internal/imapx/copy.go @@ -0,0 +1,152 @@ +package imapx + +import ( + "bytes" + "context" + "fmt" + "io" + + "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, + }).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); 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) 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)}) + if _, err := io.Copy(appendCmd, bytes.NewReader(body)); err != nil { + return err + } + if err := appendCmd.Close(); err != nil { + return err + } + _, err := appendCmd.Wait() + return err +} + +// keepFlags drops \Recent: it cannot be set via APPEND. go-imap v2 beta.8 +// no longer defines an imap.FlagRecent constant (RFC 9051 dropped \Recent +// from IMAP4rev2), so match it by its literal wire form instead. +func keepFlags(flags []imap.Flag) []imap.Flag { + out := make([]imap.Flag, 0, len(flags)) + for _, f := range flags { + if f == "\\Recent" { + continue + } + out = append(out, f) + } + return out +} diff --git a/internal/imapx/copy_test.go b/internal/imapx/copy_test.go new file mode 100644 index 0000000..f6c1dbe --- /dev/null +++ b/internal/imapx/copy_test.go @@ -0,0 +1,92 @@ +package imapx + +import ( + "context" + "fmt" + "testing" +) + +// seedInbox logs in as login/pass and APPENDs n minimal messages with unique +// Message-IDs into INBOX via a dedicated connection. +func seedInbox(t *testing.T, ep Endpoint, login, pass string, n int) { + t.Helper() + ctx := context.Background() + + c, err := Connect(ctx, ep) + if err != nil { + t.Fatalf("seedInbox connect: %v", err) + } + defer func() { _ = c.Logout().Wait() }() + + if err := c.Login(login, pass).Wait(); err != nil { + t.Fatalf("seedInbox login: %v", err) + } + + for i := 0; i < n; i++ { + msg := fmt.Sprintf( + "From: sender@localhost\r\nTo: %s\r\nSubject: seed %d\r\nMessage-Id: \r\n\r\nBody %d\r\n", + login, i, i, &i, i, + ) + buf := []byte(msg) + appendCmd := c.Append("INBOX", int64(len(buf)), nil) + if _, err := appendCmd.Write(buf); err != nil { + t.Fatalf("seedInbox write %d: %v", i, err) + } + if err := appendCmd.Close(); err != nil { + t.Fatalf("seedInbox close %d: %v", i, err) + } + if _, err := appendCmd.Wait(); err != nil { + t.Fatalf("seedInbox append %d: %v", i, err) + } + } +} + +// Требует два ящика на 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) + } +} From a54ec1d1488f07105ab5779eb02be55a1919b291 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 1 Jul 2026 17:57:12 +0700 Subject: [PATCH 14/30] fix(imapx): preserve source internal date on APPEND --- internal/imapx/copy.go | 9 +-- internal/imapx/copy_test.go | 128 ++++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 4 deletions(-) diff --git a/internal/imapx/copy.go b/internal/imapx/copy.go index c43a4bb..46ac07f 100644 --- a/internal/imapx/copy.go +++ b/internal/imapx/copy.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "io" + "time" "github.com/emersion/go-imap/v2" "github.com/emersion/go-imap/v2/imapclient" @@ -48,7 +49,7 @@ func CopyFolder(ctx context.Context, src, dst *imapclient.Client, srcFolder, dst // 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, + UID: true, Envelope: true, RFC822Size: true, Flags: true, InternalDate: true, }).Collect() if err != nil { return res, fmt.Errorf("fetch meta: %w", err) @@ -75,7 +76,7 @@ func CopyFolder(ctx context.Context, src, dst *imapclient.Client, srcFolder, dst } continue } - if err := streamOne(src, dst, dstFolder, m.UID, m.Flags); err != nil { + if err := streamOne(src, dst, dstFolder, m.UID, m.Flags, m.InternalDate); err != nil { res.Errors++ continue } @@ -94,7 +95,7 @@ func CopyFolder(ctx context.Context, src, dst *imapclient.Client, srcFolder, dst // 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) error { +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}, @@ -126,7 +127,7 @@ func streamOne(src, dst *imapclient.Client, dstFolder string, uid imap.UID, flag return fmt.Errorf("empty body uid %v", uid) } - appendCmd := dst.Append(dstFolder, int64(len(body)), &imap.AppendOptions{Flags: keepFlags(flags)}) + 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 } diff --git a/internal/imapx/copy_test.go b/internal/imapx/copy_test.go index f6c1dbe..6c73cee 100644 --- a/internal/imapx/copy_test.go +++ b/internal/imapx/copy_test.go @@ -4,6 +4,9 @@ import ( "context" "fmt" "testing" + "time" + + "github.com/emersion/go-imap/v2" ) // seedInbox logs in as login/pass and APPENDs n minimal messages with unique @@ -41,6 +44,131 @@ func seedInbox(t *testing.T, ep Endpoint, login, pass string, n int) { } } +// 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 From ec8835538be0b1c396fe6f0c7feb1a7966415e8a Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 1 Jul 2026 17:59:43 +0700 Subject: [PATCH 15/30] feat(wshub): per-task event hub with non-blocking publish --- internal/wshub/wshub.go | 57 ++++++++++++++++++++++++++++++++++++ internal/wshub/wshub_test.go | 31 ++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 internal/wshub/wshub.go create mode 100644 internal/wshub/wshub_test.go diff --git a/internal/wshub/wshub.go b/internal/wshub/wshub.go new file mode 100644 index 0000000..ba67cb7 --- /dev/null +++ b/internal/wshub/wshub.go @@ -0,0 +1,57 @@ +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) + } + } +} + +func (h *Hub) Publish(ev Event) { + h.mu.Lock() + defer h.mu.Unlock() + for _, ch := range h.subs[ev.TaskID] { + select { + case ch <- ev: + default: // медленный подписчик — событие дропаем, не блокируем воркер + } + } +} diff --git a/internal/wshub/wshub_test.go b/internal/wshub/wshub_test.go new file mode 100644 index 0000000..36bbea4 --- /dev/null +++ b/internal/wshub/wshub_test.go @@ -0,0 +1,31 @@ +package wshub + +import ( + "testing" + "time" +) + +func TestPublishReachesSubscriber(t *testing.T) { + h := New() + _, ch := h.Subscribe(7) + h.Publish(Event{Type: "progress", TaskID: 7, Data: map[string]int{"copied": 3}}) + select { + case ev := <-ch: + if ev.Type != "progress" || ev.TaskID != 7 { + t.Fatalf("bad event %+v", ev) + } + case <-time.After(time.Second): + t.Fatal("no event received") + } +} + +func TestPublishIsolatedByTask(t *testing.T) { + h := New() + _, ch := h.Subscribe(1) + h.Publish(Event{Type: "x", TaskID: 2}) + select { + case <-ch: + t.Fatal("subscriber for task 1 must not get task 2 event") + case <-time.After(100 * time.Millisecond): + } +} From 8c871d9d2681500bb2f80b2f3fcf399dbd398061 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 1 Jul 2026 18:04:44 +0700 Subject: [PATCH 16/30] feat(orchestrator): worker pool run + account testing gate --- internal/orchestrator/orchestrator.go | 219 +++++++++++++++++++++ internal/orchestrator/orchestrator_test.go | 24 +++ 2 files changed, 243 insertions(+) create mode 100644 internal/orchestrator/orchestrator.go create mode 100644 internal/orchestrator/orchestrator_test.go diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go new file mode 100644 index 0000000..de34e44 --- /dev/null +++ b/internal/orchestrator/orchestrator.go @@ -0,0 +1,219 @@ +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") + +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 + } + srcEP, dstEP, err := o.endpoints(ctx, task) + if err != nil { + return 0, err + } + runID, err := o.store.CreateRun(ctx, taskID) + if err != nil { + return 0, err + } + _ = o.store.SetTaskStatus(ctx, taskID, "running") + 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) { + 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 }() + c, s, e := o.runAccount(ctx, task, runID, a, srcEP, dstEP) + mu.Lock() + totCopied += c + totSkipped += s + totErr += e + mu.Unlock() + }(a) + } + wg.Wait() + + _ = o.store.FinishRun(ctx, runID, "done", totCopied, totSkipped, totErr) + _ = o.store.SetTaskStatus(ctx, task.ID, "done") + 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.TestLogin(ctx, srcEP, a.SrcLogin, string(srcPass)) + if err != nil { + return o.accountFailed(ctx, task.ID, a.ID, err) + } + + var copied, skipped, errs int64 + deps := imapx.CopyDeps{ + IsMigrated: func(k string) (bool, error) { return o.store.IsMigrated(ctx, a.ID, k) }, + MarkMigrated: func(folder, k string) error { return o.store.MarkMigrated(ctx, a.ID, folder, k) }, + OnProgress: func(c, s int) { + o.hub.Publish(wshub.Event{Type: "progress", TaskID: task.ID, + Data: map[string]any{"account_id": a.ID, "copied": c, "skipped": s}}) + }, + } + for _, folder := range folders { + dstFolder := folder + if m, ok := task.FolderMapping[folder]; ok { + dstFolder = m + } + res, err := imapx.CopyFolder(ctx, src, dst, folder, dstFolder, deps) + if err != nil { + slog.Warn("folder copy error", "account", a.ID, "folder", folder, "err", err) + errs++ + } + copied += int64(res.Copied) + skipped += int64(res.Skipped) + errs += int64(res.Errors) + _ = o.store.IncAccountCounters(ctx, a.ID, int64(res.Copied), int64(res.Skipped), int64(res.Errors)) + } + _ = o.store.SetAccountStatus(ctx, a.ID, "done") + o.hub.Publish(wshub.Event{Type: "account_done", TaskID: task.ID, + Data: map[string]any{"account_id": a.ID, "copied": copied, "skipped": skipped, "errors": errs}}) + slog.Info("account copied", "account", a.ID, "copied", copied, "skipped", skipped, "errors", errs) + return copied, skipped, errs +} + +func (o *Orchestrator) accountFailed(ctx context.Context, taskID, accID int64, err error) (int64, int64, int64) { + slog.Error("account failed", "account", accID, "err", err) + _ = o.store.SetAccountStatus(ctx, accID, "error") + o.hub.Publish(wshub.Event{Type: "error", TaskID: taskID, + Data: map[string]any{"account_id": accID, "error": err.Error()}}) + return 0, 0, 1 +} diff --git a/internal/orchestrator/orchestrator_test.go b/internal/orchestrator/orchestrator_test.go new file mode 100644 index 0000000..d83ea6c --- /dev/null +++ b/internal/orchestrator/orchestrator_test.go @@ -0,0 +1,24 @@ +package orchestrator + +import ( + "testing" + + "github.com/vasyansk/imap-copier/internal/store" +) + +func TestGateOK(t *testing.T) { + ok := []store.Account{ + {TestSrcStatus: "ok", TestDstStatus: "ok"}, + {TestSrcStatus: "ok", TestDstStatus: "ok"}, + } + if !gateOK(ok) { + t.Fatal("all ok must pass gate") + } + bad := []store.Account{{TestSrcStatus: "ok", TestDstStatus: "fail"}} + if gateOK(bad) { + t.Fatal("any non-ok must fail gate") + } + if gateOK(nil) { + t.Fatal("empty accounts must fail gate") + } +} From 2def11a870d746e58ad886aeb1b0881decaada26 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 1 Jul 2026 18:10:52 +0700 Subject: [PATCH 17/30] fix(orchestrator): reuse src connection to list folders instead of extra TestLogin --- internal/imapx/account.go | 15 +++++++++++++++ internal/orchestrator/orchestrator.go | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/internal/imapx/account.go b/internal/imapx/account.go index fc735d3..4b9792c 100644 --- a/internal/imapx/account.go +++ b/internal/imapx/account.go @@ -2,8 +2,23 @@ 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 { diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index de34e44..efbebdd 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -174,7 +174,7 @@ func (o *Orchestrator) runAccount(ctx context.Context, task store.Task, runID in return o.accountFailed(ctx, task.ID, a.ID, err) } - folders, err := imapx.TestLogin(ctx, srcEP, a.SrcLogin, string(srcPass)) + folders, err := imapx.ListFolders(src) if err != nil { return o.accountFailed(ctx, task.ID, a.ID, err) } From 7fe8896f4b2198190bc916ecc1f2d4e1cd8c7a22 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 1 Jul 2026 18:12:57 +0700 Subject: [PATCH 18/30] feat(csvimport): validated CSV account parser --- internal/csvimport/csvimport.go | 56 ++++++++++++++++++++++++++++ internal/csvimport/csvimport_test.go | 34 +++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 internal/csvimport/csvimport.go create mode 100644 internal/csvimport/csvimport_test.go diff --git a/internal/csvimport/csvimport.go b/internal/csvimport/csvimport.go new file mode 100644 index 0000000..b18dd97 --- /dev/null +++ b/internal/csvimport/csvimport.go @@ -0,0 +1,56 @@ +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 // проверяем сами + cr.TrimLeadingSpace = true + + var rows []Row + seen := map[string]bool{} + line := 0 + for { + rec, err := cr.Read() + if err == io.EOF { + break + } + if err != nil { + return nil, err + } + line++ + if len(rec) == 1 && strings.TrimSpace(rec[0]) == "" { + continue // пустая строка + } + if len(rec) != 4 { + return nil, fmt.Errorf("line %d: expected 4 columns, got %d", line, len(rec)) + } + for i := range rec { + rec[i] = strings.TrimSpace(rec[i]) + if rec[i] == "" { + return nil, fmt.Errorf("line %d: column %d is empty", line, i+1) + } + } + if seen[rec[0]] { + return nil, fmt.Errorf("line %d: duplicate src_login %q", line, rec[0]) + } + seen[rec[0]] = true + rows = append(rows, Row{SrcLogin: rec[0], SrcPass: rec[1], DstLogin: rec[2], DstPass: rec[3]}) + } + if len(rows) == 0 { + return nil, fmt.Errorf("no rows parsed") + } + return rows, nil +} diff --git a/internal/csvimport/csvimport_test.go b/internal/csvimport/csvimport_test.go new file mode 100644 index 0000000..3089ed0 --- /dev/null +++ b/internal/csvimport/csvimport_test.go @@ -0,0 +1,34 @@ +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") + } +} From f9f01b981b603c610db998721fe7f353ad0d81a6 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 1 Jul 2026 18:17:54 +0700 Subject: [PATCH 19/30] fix(csvimport): report accurate physical line numbers via FieldPos; add blank-line + zero-row tests --- internal/csvimport/csvimport.go | 6 ++---- internal/csvimport/csvimport_test.go | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/internal/csvimport/csvimport.go b/internal/csvimport/csvimport.go index b18dd97..9d0c342 100644 --- a/internal/csvimport/csvimport.go +++ b/internal/csvimport/csvimport.go @@ -17,11 +17,9 @@ type Row struct { func Parse(r io.Reader) ([]Row, error) { cr := csv.NewReader(r) cr.FieldsPerRecord = -1 // проверяем сами - cr.TrimLeadingSpace = true var rows []Row seen := map[string]bool{} - line := 0 for { rec, err := cr.Read() if err == io.EOF { @@ -30,9 +28,9 @@ func Parse(r io.Reader) ([]Row, error) { if err != nil { return nil, err } - line++ + line, _ := cr.FieldPos(0) if len(rec) == 1 && strings.TrimSpace(rec[0]) == "" { - continue // пустая строка + continue // encoding/csv уже пропускает голые пустые строки; это ветка ловит строки из одних пробелов } if len(rec) != 4 { return nil, fmt.Errorf("line %d: expected 4 columns, got %d", line, len(rec)) diff --git a/internal/csvimport/csvimport_test.go b/internal/csvimport/csvimport_test.go index 3089ed0..82279a3 100644 --- a/internal/csvimport/csvimport_test.go +++ b/internal/csvimport/csvimport_test.go @@ -32,3 +32,20 @@ func TestParseRejectsEmptyField(t *testing.T) { 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") + } +} From cae124931dd1c9ae2f5edcbf82a5ad2f97b7aaf4 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 1 Jul 2026 18:19:57 +0700 Subject: [PATCH 20/30] feat(httpapi): env-based login and session auth middleware --- internal/httpapi/auth.go | 67 +++++++++++++++++++++++++++++++++++ internal/httpapi/auth_test.go | 57 +++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 internal/httpapi/auth.go create mode 100644 internal/httpapi/auth_test.go diff --git a/internal/httpapi/auth.go b/internal/httpapi/auth.go new file mode 100644 index 0000000..6de0309 --- /dev/null +++ b/internal/httpapi/auth.go @@ -0,0 +1,67 @@ +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, Pass string } + 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 + } + if _, ok := crypto.VerifySession(s.cfg.SessionSecret, c.Value, time.Now()); !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} diff --git a/internal/httpapi/auth_test.go b/internal/httpapi/auth_test.go new file mode 100644 index 0000000..71b9c82 --- /dev/null +++ b/internal/httpapi/auth_test.go @@ -0,0 +1,57 @@ +package httpapi + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/vasyansk/imap-copier/internal/config" +) + +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) + } +} From 839febb83a884464e7e2eebf15ef4e74f952cfe4 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 1 Jul 2026 18:24:35 +0700 Subject: [PATCH 21/30] fix(httpapi): bind session token to current AuthUser; add negative auth tests Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01MMHQTtnQtQqL8muAXHr9kd --- internal/httpapi/auth.go | 3 ++- internal/httpapi/auth_test.go | 38 +++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/internal/httpapi/auth.go b/internal/httpapi/auth.go index 6de0309..378e3e7 100644 --- a/internal/httpapi/auth.go +++ b/internal/httpapi/auth.go @@ -58,7 +58,8 @@ func (s *Server) requireAuth(next http.Handler) http.Handler { http.Error(w, "unauthorized", http.StatusUnauthorized) return } - if _, ok := crypto.VerifySession(s.cfg.SessionSecret, c.Value, time.Now()); !ok { + user, ok := crypto.VerifySession(s.cfg.SessionSecret, c.Value, time.Now()) + if !ok || user != s.cfg.AuthUser { http.Error(w, "unauthorized", http.StatusUnauthorized) return } diff --git a/internal/httpapi/auth_test.go b/internal/httpapi/auth_test.go index 71b9c82..269c7f8 100644 --- a/internal/httpapi/auth_test.go +++ b/internal/httpapi/auth_test.go @@ -5,8 +5,10 @@ import ( "net/http/httptest" "strings" "testing" + "time" "github.com/vasyansk/imap-copier/internal/config" + "github.com/vasyansk/imap-copier/internal/crypto" ) func testServer() *Server { @@ -55,3 +57,39 @@ func TestRequireAuthAllowsValidCookie(t *testing.T) { 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) + } +} From bb83bbd989a444c5713627465c2e23715aee0668 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 1 Jul 2026 18:27:11 +0700 Subject: [PATCH 22/30] feat(httpapi): REST resources for endpoints/tasks/accounts/csv/run --- internal/httpapi/accounts.go | 68 ++++++++++++++++++++++++++++++++ internal/httpapi/dto_test.go | 21 ++++++++++ internal/httpapi/endpoints.go | 41 +++++++++++++++++++ internal/httpapi/run.go | 74 +++++++++++++++++++++++++++++++++++ internal/httpapi/tasks.go | 54 +++++++++++++++++++++++++ 5 files changed, 258 insertions(+) create mode 100644 internal/httpapi/accounts.go create mode 100644 internal/httpapi/dto_test.go create mode 100644 internal/httpapi/endpoints.go create mode 100644 internal/httpapi/run.go create mode 100644 internal/httpapi/tasks.go diff --git a/internal/httpapi/accounts.go b/internal/httpapi/accounts.go new file mode 100644 index 0000000..d86a9a6 --- /dev/null +++ b/internal/httpapi/accounts.go @@ -0,0 +1,68 @@ +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, SrcPass, DstLogin, DstPass string + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "bad json", http.StatusBadRequest) + return + } + srcEnc, err := crypto.Encrypt(s.cfg.EncKey, []byte(body.SrcPass)) + if err != nil { + http.Error(w, "encrypt", http.StatusInternalServerError) + return + } + dstEnc, err := crypto.Encrypt(s.cfg.EncKey, []byte(body.DstPass)) + if err != nil { + http.Error(w, "encrypt", http.StatusInternalServerError) + return + } + id, err := s.store.CreateAccount(r.Context(), store.Account{ + TaskID: taskID, SrcLogin: body.SrcLogin, SrcPassEnc: srcEnc, + DstLogin: body.DstLogin, DstPassEnc: dstEnc, + }) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusCreated, map[string]int64{"id": id}) +} diff --git a/internal/httpapi/dto_test.go b/internal/httpapi/dto_test.go new file mode 100644 index 0000000..35bcf74 --- /dev/null +++ b/internal/httpapi/dto_test.go @@ -0,0 +1,21 @@ +package httpapi + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/vasyansk/imap-copier/internal/store" +) + +func TestAccountDTOHidesPasswords(t *testing.T) { + a := store.Account{ID: 1, SrcLogin: "u", SrcPassEnc: "SECRET_ENC", DstLogin: "v", DstPassEnc: "SECRET2"} + b, _ := json.Marshal(accountDTO(a)) + s := string(b) + if strings.Contains(s, "SECRET_ENC") || strings.Contains(s, "SECRET2") || strings.Contains(strings.ToLower(s), "pass") { + t.Fatalf("DTO leaks password material: %s", s) + } + if !strings.Contains(s, `"src_login":"u"`) { + t.Fatalf("DTO missing login: %s", s) + } +} diff --git a/internal/httpapi/endpoints.go b/internal/httpapi/endpoints.go new file mode 100644 index 0000000..2bbf885 --- /dev/null +++ b/internal/httpapi/endpoints.go @@ -0,0 +1,41 @@ +package httpapi + +import ( + "encoding/json" + "net/http" + + "github.com/vasyansk/imap-copier/internal/store" +) + +func writeJSON(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(v) +} + +func (s *Server) handleCreateEndpoint(w http.ResponseWriter, r *http.Request) { + var e store.Endpoint + if err := json.NewDecoder(r.Body).Decode(&e); err != nil { + http.Error(w, "bad json", http.StatusBadRequest) + return + } + if e.TLSMode != "ssl" && e.TLSMode != "starttls" && e.TLSMode != "plain" { + http.Error(w, "tls_mode must be ssl|starttls|plain", http.StatusBadRequest) + return + } + id, err := s.store.CreateEndpoint(r.Context(), e) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusCreated, map[string]int64{"id": id}) +} + +func (s *Server) handleListEndpoints(w http.ResponseWriter, r *http.Request) { + eps, err := s.store.ListEndpoints(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, eps) +} diff --git a/internal/httpapi/run.go b/internal/httpapi/run.go new file mode 100644 index 0000000..45f84f4 --- /dev/null +++ b/internal/httpapi/run.go @@ -0,0 +1,74 @@ +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, _ := crypto.Encrypt(s.cfg.EncKey, []byte(row.SrcPass)) + dstEnc, _ := crypto.Encrypt(s.cfg.EncKey, []byte(row.DstPass)) + 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 err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusAccepted, map[string]int64{"run_id": runID}) +} diff --git a/internal/httpapi/tasks.go b/internal/httpapi/tasks.go new file mode 100644 index 0000000..b1a84fe --- /dev/null +++ b/internal/httpapi/tasks.go @@ -0,0 +1,54 @@ +package httpapi + +import ( + "encoding/json" + "net/http" + + "github.com/vasyansk/imap-copier/internal/store" +) + +func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) { + var t store.Task + if err := json.NewDecoder(r.Body).Decode(&t); err != nil { + http.Error(w, "bad json", http.StatusBadRequest) + return + } + id, err := s.store.CreateTask(r.Context(), t) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusCreated, map[string]int64{"id": id}) +} + +func (s *Server) handleListTasks(w http.ResponseWriter, r *http.Request) { + tasks, err := s.store.ListTasks(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, tasks) +} + +func (s *Server) handleGetTask(w http.ResponseWriter, r *http.Request) { + id, err := pathID(r, "id") + if err != nil { + http.Error(w, "bad id", http.StatusBadRequest) + return + } + task, err := s.store.GetTask(r.Context(), id) + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + accs, err := s.store.ListAccountsByTask(r.Context(), id) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + views := make([]AccountView, 0, len(accs)) + for _, a := range accs { + views = append(views, accountDTO(a)) + } + writeJSON(w, http.StatusOK, map[string]any{"task": task, "accounts": views}) +} From 0bd4ba37e3b1a43041c78d88784d425a1bc985ee Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 1 Jul 2026 18:33:09 +0700 Subject: [PATCH 23/30] fix(httpapi): fail CSV import on encryption error instead of storing empty passwords --- internal/httpapi/run.go | 12 ++++++++++-- internal/httpapi/run_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 internal/httpapi/run_test.go diff --git a/internal/httpapi/run.go b/internal/httpapi/run.go index 45f84f4..bbedaab 100644 --- a/internal/httpapi/run.go +++ b/internal/httpapi/run.go @@ -29,8 +29,16 @@ func (s *Server) handleImportCSV(w http.ResponseWriter, r *http.Request) { return } for _, row := range rows { - srcEnc, _ := crypto.Encrypt(s.cfg.EncKey, []byte(row.SrcPass)) - dstEnc, _ := crypto.Encrypt(s.cfg.EncKey, []byte(row.DstPass)) + 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, diff --git a/internal/httpapi/run_test.go b/internal/httpapi/run_test.go new file mode 100644 index 0000000..2e7e04b --- /dev/null +++ b/internal/httpapi/run_test.go @@ -0,0 +1,28 @@ +package httpapi + +import ( + "mime/multipart" + "net/http/httptest" + "strings" + "testing" + + "github.com/vasyansk/imap-copier/internal/config" +) + +func TestImportCSVFailsOnBadEncKey(t *testing.T) { + // EncKey wrong size => crypto.Encrypt errors => handler must NOT return success + s := &Server{cfg: config.Config{EncKey: make([]byte, 16)}} + body := &strings.Builder{} + mw := multipart.NewWriter(body) + fw, _ := mw.CreateFormFile("file", "a.csv") + fw.Write([]byte("a@x,p1,a@y,p2\n")) + mw.Close() + req := httptest.NewRequest("POST", "/api/tasks/1/import", strings.NewReader(body.String())) + req.Header.Set("Content-Type", mw.FormDataContentType()) + req.SetPathValue("id", "1") + rw := httptest.NewRecorder() + s.handleImportCSV(rw, req) + if rw.Code == 200 || rw.Code == 201 { + t.Fatalf("import must fail on bad EncKey, got %d", rw.Code) + } +} From 9ec6acd414a2381ccfff7cdf92690c499c780588 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 1 Jul 2026 18:37:48 +0700 Subject: [PATCH 24/30] feat(httpapi): websocket, router, embed static, main entrypoint --- cmd/server/main.go | 56 ++++++++++++++++++++ go.mod | 12 +++-- go.sum | 79 +++++++++++++++++++++++++---- internal/httpapi/router.go | 30 +++++++++++ internal/httpapi/router_test.go | 27 ++++++++++ internal/httpapi/static.go | 35 +++++++++++++ internal/httpapi/webdist/index.html | 1 + internal/httpapi/ws.go | 45 ++++++++++++++++ 8 files changed, 269 insertions(+), 16 deletions(-) create mode 100644 cmd/server/main.go create mode 100644 internal/httpapi/router.go create mode 100644 internal/httpapi/router_test.go create mode 100644 internal/httpapi/static.go create mode 100644 internal/httpapi/webdist/index.html create mode 100644 internal/httpapi/ws.go diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..c97dcc4 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,56 @@ +package main + +import ( + "context" + "log/slog" + "net/http" + "os" + + "github.com/golang-migrate/migrate/v4" + _ "github.com/golang-migrate/migrate/v4/database/postgres" + _ "github.com/golang-migrate/migrate/v4/source/file" + + "github.com/vasyansk/imap-copier/internal/config" + "github.com/vasyansk/imap-copier/internal/httpapi" + "github.com/vasyansk/imap-copier/internal/orchestrator" + "github.com/vasyansk/imap-copier/internal/store" + "github.com/vasyansk/imap-copier/internal/wshub" +) + +func main() { + slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil))) + cfg, err := config.Load() + if err != nil { + slog.Error("config", "err", err) + os.Exit(1) + } + if err := runMigrations(cfg.DatabaseURL); err != nil { + slog.Error("migrate", "err", err) + os.Exit(1) + } + st, err := store.New(context.Background(), cfg.DatabaseURL) + if err != nil { + slog.Error("store", "err", err) + os.Exit(1) + } + hub := wshub.New() + orch := orchestrator.New(st, hub, cfg.EncKey, cfg.WorkerConcurrency) + srv := httpapi.NewServer(cfg, st, orch, hub) + + slog.Info("listening", "addr", cfg.HTTPAddr) + if err := http.ListenAndServe(cfg.HTTPAddr, srv.Router()); err != nil { + slog.Error("serve", "err", err) + os.Exit(1) + } +} + +func runMigrations(dsn string) error { + m, err := migrate.New("file://migrations", dsn) + if err != nil { + return err + } + if err := m.Up(); err != nil && err != migrate.ErrNoChange { + return err + } + return nil +} diff --git a/go.mod b/go.mod index a132be3..fa3c0f4 100644 --- a/go.mod +++ b/go.mod @@ -1,9 +1,11 @@ module github.com/vasyansk/imap-copier -go 1.22.0 +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 ) @@ -13,8 +15,8 @@ require ( 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/stretchr/testify v1.9.0 // indirect - golang.org/x/crypto v0.31.0 // indirect - golang.org/x/sync v0.10.0 // indirect - golang.org/x/text v0.21.0 // indirect + github.com/lib/pq v1.10.9 // indirect + golang.org/x/crypto v0.45.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/text v0.31.0 // indirect ) diff --git a/go.sum b/go.sum index e90854c..1b299c5 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,42 @@ +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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/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= @@ -15,18 +45,43 @@ 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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +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.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +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.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +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= @@ -36,14 +91,16 @@ 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.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +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= @@ -52,8 +109,8 @@ 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.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +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= diff --git a/internal/httpapi/router.go b/internal/httpapi/router.go new file mode 100644 index 0000000..f33e774 --- /dev/null +++ b/internal/httpapi/router.go @@ -0,0 +1,30 @@ +package httpapi + +import "net/http" + +func (s *Server) Router() http.Handler { + mux := http.NewServeMux() + + // открытые + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }) + mux.HandleFunc("POST /api/login", s.handleLogin) + mux.HandleFunc("POST /api/logout", s.handleLogout) + + // защищённые + api := http.NewServeMux() + api.HandleFunc("GET /api/endpoints", s.handleListEndpoints) + api.HandleFunc("POST /api/endpoints", s.handleCreateEndpoint) + api.HandleFunc("GET /api/tasks", s.handleListTasks) + api.HandleFunc("POST /api/tasks", s.handleCreateTask) + api.HandleFunc("GET /api/tasks/{id}", s.handleGetTask) + api.HandleFunc("POST /api/tasks/{id}/accounts", s.handleCreateAccount) + api.HandleFunc("POST /api/tasks/{id}/import", s.handleImportCSV) + api.HandleFunc("POST /api/tasks/{id}/test", s.handleTestAccounts) + api.HandleFunc("POST /api/tasks/{id}/run", s.handleRun) + mux.Handle("/api/", s.requireAuth(api)) + mux.Handle("/ws", s.requireAuth(http.HandlerFunc(s.handleWS))) + + // SPA static (fallback) + mux.Handle("/", s.staticHandler()) + return mux +} diff --git a/internal/httpapi/router_test.go b/internal/httpapi/router_test.go new file mode 100644 index 0000000..4826eb3 --- /dev/null +++ b/internal/httpapi/router_test.go @@ -0,0 +1,27 @@ +package httpapi + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/vasyansk/imap-copier/internal/config" +) + +func TestHealthzOpen(t *testing.T) { + s := &Server{cfg: config.Config{SessionSecret: []byte("x")}} + rw := httptest.NewRecorder() + s.Router().ServeHTTP(rw, httptest.NewRequest("GET", "/healthz", nil)) + if rw.Code != http.StatusOK { + t.Fatalf("healthz=%d", rw.Code) + } +} + +func TestTasksRequiresAuth(t *testing.T) { + s := &Server{cfg: config.Config{SessionSecret: []byte("x")}} + rw := httptest.NewRecorder() + s.Router().ServeHTTP(rw, httptest.NewRequest("GET", "/api/tasks", nil)) + if rw.Code != http.StatusUnauthorized { + t.Fatalf("want 401, got %d", rw.Code) + } +} diff --git a/internal/httpapi/static.go b/internal/httpapi/static.go new file mode 100644 index 0000000..beca83e --- /dev/null +++ b/internal/httpapi/static.go @@ -0,0 +1,35 @@ +package httpapi + +import ( + "embed" + "io/fs" + "net/http" +) + +//go:embed all:webdist +var webDist embed.FS + +func (s *Server) staticHandler() http.Handler { + sub, err := fs.Sub(webDist, "webdist") + if err != nil { + panic(err) + } + fileServer := http.FileServer(http.FS(sub)) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // SPA fallback: если файла нет — отдать index.html + if _, err := fs.Stat(sub, trimLead(r.URL.Path)); err != nil && r.URL.Path != "/" { + r2 := r.Clone(r.Context()) + r2.URL.Path = "/" + fileServer.ServeHTTP(w, r2) + return + } + fileServer.ServeHTTP(w, r) + }) +} + +func trimLead(p string) string { + if len(p) > 0 && p[0] == '/' { + return p[1:] + } + return p +} diff --git a/internal/httpapi/webdist/index.html b/internal/httpapi/webdist/index.html new file mode 100644 index 0000000..47567c2 --- /dev/null +++ b/internal/httpapi/webdist/index.html @@ -0,0 +1 @@ +imap-copier diff --git a/internal/httpapi/ws.go b/internal/httpapi/ws.go new file mode 100644 index 0000000..177ced9 --- /dev/null +++ b/internal/httpapi/ws.go @@ -0,0 +1,45 @@ +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) + + ctx := 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 + } + } +} From 4c57848c35ad1ac870b80afb003f841bff1317d1 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 1 Jul 2026 18:47:54 +0700 Subject: [PATCH 25/30] fix(httpapi): detect ws client disconnect via CloseRead to prevent subscriber leak Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01MMHQTtnQtQqL8muAXHr9kd --- internal/httpapi/ws.go | 5 ++- internal/httpapi/ws_test.go | 63 +++++++++++++++++++++++++++++++++++++ internal/wshub/wshub.go | 7 +++++ 3 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 internal/httpapi/ws_test.go diff --git a/internal/httpapi/ws.go b/internal/httpapi/ws.go index 177ced9..f75a9a1 100644 --- a/internal/httpapi/ws.go +++ b/internal/httpapi/ws.go @@ -25,7 +25,10 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { subID, ch := s.hub.Subscribe(taskID) defer s.hub.Unsubscribe(taskID, subID) - ctx := r.Context() + // websocket.Accept хайджекает соединение, поэтому r.Context() не отменяется + // при обрыве связи клиентом. CloseRead запускает фоновое чтение control-фреймов + // и отменяет возвращаемый контекст, когда соединение действительно умирает. + ctx := c.CloseRead(r.Context()) for { select { case ev, ok := <-ch: diff --git a/internal/httpapi/ws_test.go b/internal/httpapi/ws_test.go new file mode 100644 index 0000000..34b6587 --- /dev/null +++ b/internal/httpapi/ws_test.go @@ -0,0 +1,63 @@ +package httpapi + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/coder/websocket" + "github.com/vasyansk/imap-copier/internal/config" + "github.com/vasyansk/imap-copier/internal/crypto" + "github.com/vasyansk/imap-copier/internal/wshub" +) + +func TestWSRequiresAuth(t *testing.T) { + s := &Server{cfg: config.Config{SessionSecret: []byte("x")}, hub: wshub.New()} + srv := httptest.NewServer(s.Router()) + defer srv.Close() + // no cookie -> upgrade rejected (401) + _, resp, err := websocket.Dial(context.Background(), "ws"+srv.URL[4:]+"/ws?task_id=1", nil) + if err == nil { + t.Fatal("expected auth rejection") + } + if resp != nil && resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("want 401, got %d", resp.StatusCode) + } +} + +func TestWSUnsubscribesOnClientDisconnect(t *testing.T) { + hub := wshub.New() + secret := []byte("sekret") + s := &Server{cfg: config.Config{AuthUser: "admin", SessionSecret: secret}, hub: hub} + srv := httptest.NewServer(s.Router()) + defer srv.Close() + + tok := crypto.SignSession(secret, "admin", time.Now().Add(time.Hour)) + hdr := http.Header{} + hdr.Set("Cookie", cookieName+"="+tok) + + ctx := context.Background() + c, _, err := websocket.Dial(ctx, "ws"+srv.URL[4:]+"/ws?task_id=7", &websocket.DialOptions{HTTPHeader: hdr}) + if err != nil { + t.Fatalf("dial: %v", err) + } + // wait until subscribed + deadline := time.Now().Add(2 * time.Second) + for hub.SubscriberCount(7) == 0 { + if time.Now().After(deadline) { + t.Fatal("never subscribed") + } + time.Sleep(10 * time.Millisecond) + } + // abrupt client close -> server must detect and unsubscribe + c.CloseNow() + deadline = time.Now().Add(3 * time.Second) + for hub.SubscriberCount(7) != 0 { + if time.Now().After(deadline) { + t.Fatal("subscription leaked after client disconnect") + } + time.Sleep(20 * time.Millisecond) + } +} diff --git a/internal/wshub/wshub.go b/internal/wshub/wshub.go index ba67cb7..130b12a 100644 --- a/internal/wshub/wshub.go +++ b/internal/wshub/wshub.go @@ -45,6 +45,13 @@ func (h *Hub) Unsubscribe(taskID, id int64) { } } +// 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() From 1a451f9dbb634b63b6f68fe7247026c224345b35 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 1 Jul 2026 19:01:05 +0700 Subject: [PATCH 26/30] feat(web): React SPA with realtime task detail over WebSocket Vite + React 19 + TS console-style operator UI: hash-routed Login, Endpoints, Tasks, and TaskDetail (realtime accounts table over /ws, Run gated on all accounts testing ok on both sides). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01MMHQTtnQtQqL8muAXHr9kd --- web/.gitignore | 24 + web/.oxlintrc.json | 8 + web/README.md | 32 + web/index.html | 14 + web/package-lock.json | 1439 ++++++++++++++++++++++++++++ web/package.json | 27 + web/public/favicon.svg | 5 + web/src/App.tsx | 88 ++ web/src/api.ts | 98 ++ web/src/app.css | 568 +++++++++++ web/src/components/StatusBadge.tsx | 13 + web/src/index.css | 98 ++ web/src/main.tsx | 10 + web/src/pages/Endpoints.tsx | 145 +++ web/src/pages/Login.tsx | 61 ++ web/src/pages/TaskDetail.tsx | 289 ++++++ web/src/pages/Tasks.tsx | 156 +++ web/src/ws.ts | 30 + web/tsconfig.app.json | 26 + web/tsconfig.json | 7 + web/tsconfig.node.json | 23 + web/vite.config.ts | 14 + 22 files changed, 3175 insertions(+) create mode 100644 web/.gitignore create mode 100644 web/.oxlintrc.json create mode 100644 web/README.md create mode 100644 web/index.html create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/public/favicon.svg create mode 100644 web/src/App.tsx create mode 100644 web/src/api.ts create mode 100644 web/src/app.css create mode 100644 web/src/components/StatusBadge.tsx create mode 100644 web/src/index.css create mode 100644 web/src/main.tsx create mode 100644 web/src/pages/Endpoints.tsx create mode 100644 web/src/pages/Login.tsx create mode 100644 web/src/pages/TaskDetail.tsx create mode 100644 web/src/pages/Tasks.tsx create mode 100644 web/src/ws.ts create mode 100644 web/tsconfig.app.json create mode 100644 web/tsconfig.json create mode 100644 web/tsconfig.node.json create mode 100644 web/vite.config.ts diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/web/.oxlintrc.json b/web/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/web/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..d6af7e3 --- /dev/null +++ b/web/README.md @@ -0,0 +1,32 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the Oxlint configuration + +If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`: + +```json +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "options": { + "typeAware": true + }, + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} +``` + +See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories. diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..f789588 --- /dev/null +++ b/web/index.html @@ -0,0 +1,14 @@ + + + + + + + + imap/copier — operator console + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..f393009 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,1439 @@ +{ + "name": "web", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "web", + "version": "0.0.0", + "dependencies": { + "@fontsource/big-shoulders-display": "^5.2.5", + "@fontsource/jetbrains-mono": "^5.2.8", + "react": "^19.2.7", + "react-dom": "^19.2.7" + }, + "devDependencies": { + "@types/node": "^24.13.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "oxlint": "^1.71.0", + "typescript": "~6.0.2", + "vite": "^8.1.1" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@fontsource/big-shoulders-display": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@fontsource/big-shoulders-display/-/big-shoulders-display-5.2.5.tgz", + "integrity": "sha512-qqqqNaT2DRcrpytJ82ZjFeDsQFdrncGna3OqLS+F9XwOS65rxOnXFBgnubh3hQVj8RzUS/LQNVtUXvdsZLKtkA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/jetbrains-mono": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz", + "integrity": "sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", + "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.72.0.tgz", + "integrity": "sha512-zhCmvn+1Mj3UchAc/90i99S0t7jJUsHmFVSPg4UWrjO8b8eaSGwscgO6QAUtvHBstkjQwBttQNswEnAF1mIQdA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.72.0.tgz", + "integrity": "sha512-mtH+aY/ozv1eZoCUC2owjFAtyNBKHpJHygKeEu9zXXnQGW1Q2/qOpvx+I+Lf23+TvTz66F4iiXUbl2cGvoLPCQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.72.0.tgz", + "integrity": "sha512-EvnajNPDtfknB3ZieeOOyDTwJn9QXDiwfnF4ZDQqART6RG6hjY4WigQcZdGoK2dkB3e1vrmEzN9aYbQCUkh/gQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.72.0.tgz", + "integrity": "sha512-ZkCdEa/G80A7vEHfeCDz/+L3m33DE73v32mDKhgOIgz8Uwf0DFcK7+uu6qC+7LEhmz5fpOe1osWKyjSNMydFIQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.72.0.tgz", + "integrity": "sha512-NroXv2vh+sxVY1uya/rM5pjhx1hm8BzlYpx9q67QP0Xhw5MH2bf5GJylpvLEC+781p1Xli/317EoV9AlGwViag==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.72.0.tgz", + "integrity": "sha512-0NDywYgfj279Ou/BcQuCYSj7NJwBfmWn5qc5uGO/Ny7fUWmXyIpvawqX/8acQlWG6IXelJsJhj+JAy6sjsKj0A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.72.0.tgz", + "integrity": "sha512-4vpXB06h65Ezsy4hRyrGjGrfa1SkVPii09yaajiYhmVpgsFiLD+KNxIx/BNAY+XiO+i1yqp9HHdwqM8VTqa5XQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.72.0.tgz", + "integrity": "sha512-immaN4g2ZGFiOkKrvRX9LvzZdd2GkQM5wR+UyzYyUuyhUTXGQ4HKUJH18xp4G8OfhCVaVAJfKZxwE1r8+4hhaQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.72.0.tgz", + "integrity": "sha512-JGHS9Mnr7iWyyLDxgCv1MhzVpAckgptg00F2gnxt/GD7lQ2SW1BRcxHqhSTaSdDpjWRrBkBxMMh4+Hn3aVtExg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.72.0.tgz", + "integrity": "sha512-AOYgBZqxNshrg83P9v0RYv+m8s10Cqkj4/PxXFDhcS3k7FqsIG5+CxErshZCIN7G8iy4Y+VGfAsuEdar8AcbBg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.72.0.tgz", + "integrity": "sha512-QMybPS5ij3/vrKG67mqzHwW++91sYxK/PPUVi6SBtNCEzW4niS52fVBdXbQ6nou0wWbUPEpx8Sl/ZjtgE3clXA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.72.0.tgz", + "integrity": "sha512-gOc3W7JV0PXRpIL7stUlLe3Wa9Gp0Kdlup87IT3gHDvPKck2xNgMIl/Gs2lldYY2lyXZDC4rWi3hmoLUobkgbQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.72.0.tgz", + "integrity": "sha512-rpGxph+FjjHcYI5q6uxB3Az+tnfmEnDbSA8+PK9ZE/VzyUAkvBOMeuY7ZQMhu5mpZH7YQDsTdW6Cx4kV/msc6w==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.72.0.tgz", + "integrity": "sha512-WND+uhf/Ko13SLqQMWQUgsZuLvYYEvL0ZKgg0tgGYfLqxG7l8Ju123fHDMJyYSDl5E3bUbpFUuii/OvMreFQzw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.72.0.tgz", + "integrity": "sha512-SrpbrUL70nG9vh6zP4/oKHWgLuHquwsr7MW9XOn0olBVgh10Uqr8qscKhQoBGEn6olK/IUpn5GSKcdQ5AjUhGA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.72.0.tgz", + "integrity": "sha512-qkrsEn6NmgFKr7U/QnezQMb+q/vzAy0Dd9Y95gQGQTyjzDLN+HRZMuM5u70iyH4nBLCfKBzhjMsYCehKay2jyg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.72.0.tgz", + "integrity": "sha512-LWR6ZlFZph+KPjXv8opgZsXRDCdrdQe8VL8Cg9zxCoBS73h6znzZpydVgmdnwj8mB9AuSM5jxEgDJDpQkjboeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.72.0.tgz", + "integrity": "sha512-yt6HEh7IsHvtjRWtmeZRX134eaXKHq5Gnqlf1xBJdJl1JtdoRUEJw3nAxpZoUDS860cX/foKbztO441anVBtVQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.72.0.tgz", + "integrity": "sha512-b2eKFD2hX7tIwmo/cyH6TDq8vzWRZ2qNHrzoGntUTmq0h3zQh/uX3eTSHCwI8OB/ADQfJCRelLItK8BsxuucDA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", + "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz", + "integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz", + "integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz", + "integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz", + "integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz", + "integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz", + "integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz", + "integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz", + "integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz", + "integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz", + "integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz", + "integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz", + "integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz", + "integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz", + "integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/node": { + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/oxlint": { + "version": "1.72.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.72.0.tgz", + "integrity": "sha512-1rhdZIP/EvoI91ABIwNU5Q8+bWf8mjrS5UzIOZld4d4bXxJvtlUhlQvaoTogIGin/qdErMOrwaIJvCSIAKTLhA==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.72.0", + "@oxlint/binding-android-arm64": "1.72.0", + "@oxlint/binding-darwin-arm64": "1.72.0", + "@oxlint/binding-darwin-x64": "1.72.0", + "@oxlint/binding-freebsd-x64": "1.72.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.72.0", + "@oxlint/binding-linux-arm-musleabihf": "1.72.0", + "@oxlint/binding-linux-arm64-gnu": "1.72.0", + "@oxlint/binding-linux-arm64-musl": "1.72.0", + "@oxlint/binding-linux-ppc64-gnu": "1.72.0", + "@oxlint/binding-linux-riscv64-gnu": "1.72.0", + "@oxlint/binding-linux-riscv64-musl": "1.72.0", + "@oxlint/binding-linux-s390x-gnu": "1.72.0", + "@oxlint/binding-linux-x64-gnu": "1.72.0", + "@oxlint/binding-linux-x64-musl": "1.72.0", + "@oxlint/binding-openharmony-arm64": "1.72.0", + "@oxlint/binding-win32-arm64-msvc": "1.72.0", + "@oxlint/binding-win32-ia32-msvc": "1.72.0", + "@oxlint/binding-win32-x64-msvc": "1.72.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=0.22.1", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/rolldown": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz", + "integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.137.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.3", + "@rolldown/binding-darwin-arm64": "1.1.3", + "@rolldown/binding-darwin-x64": "1.1.3", + "@rolldown/binding-freebsd-x64": "1.1.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", + "@rolldown/binding-linux-arm64-gnu": "1.1.3", + "@rolldown/binding-linux-arm64-musl": "1.1.3", + "@rolldown/binding-linux-ppc64-gnu": "1.1.3", + "@rolldown/binding-linux-s390x-gnu": "1.1.3", + "@rolldown/binding-linux-x64-gnu": "1.1.3", + "@rolldown/binding-linux-x64-musl": "1.1.3", + "@rolldown/binding-openharmony-arm64": "1.1.3", + "@rolldown/binding-wasm32-wasi": "1.1.3", + "@rolldown/binding-win32-arm64-msvc": "1.1.3", + "@rolldown/binding-win32-x64-msvc": "1.1.3" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.2.tgz", + "integrity": "sha512-6YYPbRXTxx6bRXmOn7XdnQAy5DQNHhDgtjhDHI13oe4pY93kkcdGJWxpGwOm++/Wh0QpQhDrpIoVMrmrsI5AGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..afe61d1 --- /dev/null +++ b/web/package.json @@ -0,0 +1,27 @@ +{ + "name": "web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "oxlint", + "preview": "vite preview" + }, + "dependencies": { + "@fontsource/big-shoulders-display": "^5.2.5", + "@fontsource/jetbrains-mono": "^5.2.8", + "react": "^19.2.7", + "react-dom": "^19.2.7" + }, + "devDependencies": { + "@types/node": "^24.13.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "oxlint": "^1.71.0", + "typescript": "~6.0.2", + "vite": "^8.1.1" + } +} diff --git a/web/public/favicon.svg b/web/public/favicon.svg new file mode 100644 index 0000000..0c5f8a5 --- /dev/null +++ b/web/public/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..f435c8d --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,88 @@ +import { useEffect, useState } from 'react' +import './app.css' +import { logout } from './api' +import { Login } from './pages/Login' +import { Endpoints } from './pages/Endpoints' +import { Tasks } from './pages/Tasks' +import { TaskDetail } from './pages/TaskDetail' + +type Route = + | { name: 'login' } + | { name: 'tasks' } + | { name: 'endpoints' } + | { name: 'task'; id: number } + | { name: 'notfound' } + +function parseRoute(hash: string): Route { + const path = hash.replace(/^#/, '') || '/' + if (path === '/login') return { name: 'login' } + if (path === '/') return { name: 'tasks' } + if (path === '/endpoints') return { name: 'endpoints' } + const m = path.match(/^\/tasks\/(\d+)$/) + if (m) return { name: 'task', id: Number(m[1]) } + return { name: 'notfound' } +} + +function useHashRoute(): Route { + const [hash, setHash] = useState(location.hash) + useEffect(() => { + const onChange = () => setHash(location.hash) + window.addEventListener('hashchange', onChange) + return () => window.removeEventListener('hashchange', onChange) + }, []) + return parseRoute(hash) +} + +function App() { + const route = useHashRoute() + + if (route.name === 'login') { + return (location.hash = '#/')} /> + } + + function handleLogout() { + logout() + .catch(() => {}) + .finally(() => (location.hash = '#/login')) + } + + return ( +
+
+
+ [IMAP/COPIER + ] +
+ +
+ session active +
+ +
+
+ {route.name === 'tasks' && } + {route.name === 'endpoints' && } + {route.name === 'task' && } + {route.name === 'notfound' && ( +
+

Unknown route.

+ + ← back to tasks + +
+ )} +
+
+ ) +} + +export default App diff --git a/web/src/api.ts b/web/src/api.ts new file mode 100644 index 0000000..8cf3310 --- /dev/null +++ b/web/src/api.ts @@ -0,0 +1,98 @@ +// REST client for the imap-copier control API. +// All requests carry the session cookie; a 401 anywhere bounces to #/login. + +export type TLSMode = 'ssl' | 'starttls' | 'plain' + +export interface Endpoint { + id: number + role_label: string + host: string + port: number + tls_mode: TLSMode +} + +export interface Task { + id: number + name: string + src_endpoint_id: number + dst_endpoint_id: number + status: string + folder_mapping?: Record +} + +export type TestStatus = 'pending' | 'ok' | 'fail' | string + +export interface Account { + id: number + src_login: string + dst_login: string + test_src_status: TestStatus + test_dst_status: TestStatus + status: string + copied: number + skipped: number + errors: number +} + +export interface TaskDetail { + task: Task + accounts: Account[] +} + +export class ApiError extends Error {} + +export async function api(path: string, opts: RequestInit = {}): Promise { + const res = await fetch(path, { credentials: 'include', ...opts }) + if (res.status === 401) { + location.hash = '#/login' + throw new ApiError('unauthorized') + } + if (!res.ok) { + const body = await res.text() + throw new ApiError(body || res.statusText) + } + const ct = res.headers.get('content-type') || '' + if (ct.includes('application/json')) return res.json() as Promise + return res.text() as unknown as T +} + +const jsonBody = (body: unknown): RequestInit => ({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), +}) + +export const login = (user: string, pass: string) => api('/api/login', jsonBody({ user, pass })) + +export const logout = () => api('/api/logout', { method: 'POST' }) + +export const listEndpoints = () => api('/api/endpoints') + +export const createEndpoint = (body: { role_label: string; host: string; port: number; tls_mode: TLSMode }) => + api<{ id: number }>('/api/endpoints', jsonBody(body)) + +export const listTasks = () => api('/api/tasks') + +export const getTask = (id: number) => api(`/api/tasks/${id}`) + +export const createTask = (body: { + name: string + src_endpoint_id: number + dst_endpoint_id: number + folder_mapping?: Record +}) => api<{ id: number }>('/api/tasks', jsonBody(body)) + +export const createAccount = ( + id: number, + body: { src_login: string; src_pass: string; dst_login: string; dst_pass: string }, +) => api<{ id: number }>(`/api/tasks/${id}/accounts`, jsonBody(body)) + +export const testAccounts = (id: number) => api(`/api/tasks/${id}/test`, { method: 'POST' }) + +export const runTask = (id: number) => api(`/api/tasks/${id}/run`, { method: 'POST' }) + +export const importCSV = (id: number, file: File) => { + const fd = new FormData() + fd.append('file', file) + return api<{ imported: number }>(`/api/tasks/${id}/import`, { method: 'POST', body: fd }) +} diff --git a/web/src/app.css b/web/src/app.css new file mode 100644 index 0000000..ecc98f1 --- /dev/null +++ b/web/src/app.css @@ -0,0 +1,568 @@ +/* ---------- layout shell ---------- */ + +.shell { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.topbar { + display: flex; + align-items: center; + gap: 28px; + padding: 0 24px; + height: 56px; + background: var(--bg-panel-raised); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 10; +} + +.brand { + display: flex; + align-items: baseline; + gap: 2px; + font-family: var(--font-display); + font-weight: 800; + font-size: 22px; + letter-spacing: 0.5px; + text-transform: uppercase; + white-space: nowrap; +} + +.brand .bracket { + color: var(--accent); +} + +.brand .dim { + color: var(--fg-dim); + font-weight: 600; +} + +.topnav { + display: flex; + gap: 4px; + flex: 1; +} + +.topnav a { + display: inline-block; + padding: 8px 14px; + text-decoration: none; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--fg-dim); + border: 1px solid transparent; + border-radius: 2px; + transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease; +} + +.topnav a:hover { + color: var(--fg); + border-color: var(--border); +} + +.topnav a.active { + color: var(--accent-strong); + border-color: var(--accent-dim); + background: rgba(255, 178, 56, 0.06); +} + +.session-indicator { + display: flex; + align-items: center; + gap: 8px; + font-size: 11px; + color: var(--fg-dim); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.pulse-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--ok); + box-shadow: 0 0 6px 1px var(--ok); + animation: pulse 2.4s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.35; } +} + +.main { + flex: 1; + width: 100%; + max-width: 1180px; + margin: 0 auto; + padding: 32px 24px 64px; +} + +.page-head { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; + margin-bottom: 24px; + flex-wrap: wrap; +} + +.page-title { + font-family: var(--font-display); + font-weight: 800; + font-size: 34px; + letter-spacing: 0.3px; + text-transform: uppercase; + margin: 0; + display: flex; + align-items: center; + gap: 12px; +} + +.page-title .idx { + font-family: var(--font-mono); + font-size: 13px; + font-weight: 500; + color: var(--fg-faint); + letter-spacing: 0.1em; +} + +.crumb { + font-size: 12px; + color: var(--fg-dim); + letter-spacing: 0.08em; + text-transform: uppercase; + text-decoration: none; + border-bottom: 1px dashed var(--border-bright); +} + +.crumb:hover { + color: var(--accent-strong); +} + +/* ---------- panels ---------- */ + +.panel { + position: relative; + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: 3px; + padding: 20px; + margin-bottom: 20px; +} + +.panel-label { + position: absolute; + top: -9px; + left: 14px; + background: var(--bg); + padding: 0 8px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--accent); +} + +.panel-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); + gap: 20px; +} + +/* ---------- forms ---------- */ + +.field { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 14px; +} + +.field label { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--fg-dim); +} + +.field input, +.field select { + background: var(--bg-inset); + border: 1px solid var(--border); + color: var(--fg); + padding: 9px 11px; + font-size: 13px; + border-radius: 2px; + outline: none; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.field input:focus, +.field select:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(255, 178, 56, 0.12); +} + +.field-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.hint { + font-size: 11px; + color: var(--fg-faint); +} + +/* ---------- buttons ---------- */ + +.btn { + appearance: none; + border: 1px solid var(--border-bright); + background: transparent; + color: var(--fg); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + padding: 10px 18px; + border-radius: 2px; + transition: all 0.15s ease; +} + +.btn:hover:not(:disabled) { + border-color: var(--fg-dim); + color: var(--accent-strong); +} + +.btn-primary { + background: var(--accent); + border-color: var(--accent); + color: #1a1200; +} + +.btn-primary:hover:not(:disabled) { + background: var(--accent-strong); + border-color: var(--accent-strong); + color: #1a1200; + box-shadow: 0 0 16px -2px rgba(255, 178, 56, 0.6); +} + +.btn:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.btn-row { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; +} + +.btn-ghost { + border-color: transparent; + color: var(--fg-dim); + padding: 8px 10px; +} + +.btn-ghost:hover:not(:disabled) { + color: var(--fail); +} + +/* ---------- status badges ---------- */ + +.badge { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + padding: 3px 9px 3px 7px; + border-radius: 2px; + border: 1px solid; + white-space: nowrap; +} + +.badge .dot { + width: 6px; + height: 6px; + border-radius: 50%; +} + +.badge-ok { + color: var(--ok); + border-color: var(--ok-dim); + background: rgba(82, 230, 160, 0.06); +} +.badge-ok .dot { background: var(--ok); box-shadow: 0 0 5px var(--ok); } + +.badge-fail { + color: var(--fail); + border-color: var(--fail-dim); + background: rgba(255, 93, 93, 0.06); +} +.badge-fail .dot { background: var(--fail); box-shadow: 0 0 5px var(--fail); } + +.badge-pending { + color: var(--pending); + border-color: #4a4423; + background: rgba(240, 196, 25, 0.06); +} +.badge-pending .dot { background: var(--pending); } + +.badge-info { + color: var(--info); + border-color: #234456; + background: rgba(87, 194, 255, 0.06); +} +.badge-info .dot { background: var(--info); animation: pulse 1.4s ease-in-out infinite; } + +/* ---------- tables ---------- */ + +.tbl-wrap { + overflow-x: auto; + border: 1px solid var(--border); + border-radius: 3px; +} + +table.tbl { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +table.tbl thead th { + text-align: left; + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--fg-dim); + background: var(--bg-panel-raised); + padding: 10px 14px; + border-bottom: 1px solid var(--border); + white-space: nowrap; +} + +table.tbl tbody td { + padding: 10px 14px; + border-bottom: 1px solid var(--border); + vertical-align: middle; +} + +table.tbl tbody tr:last-child td { + border-bottom: none; +} + +table.tbl tbody tr:hover { + background: rgba(255, 255, 255, 0.015); +} + +table.tbl a.rowlink { + color: var(--fg); + text-decoration: none; +} +table.tbl a.rowlink:hover { + color: var(--accent-strong); +} + +.num-cell { + font-variant-numeric: tabular-nums; + text-align: right; +} + +.empty-row td { + text-align: center; + color: var(--fg-faint); + padding: 28px 14px; + font-style: normal; +} + +/* ---------- login ---------- */ + +.login-wrap { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + position: relative; +} + +.login-card { + width: 100%; + max-width: 380px; + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: 3px; + padding: 32px 28px; + position: relative; +} + +.login-card::before { + content: ''; + position: absolute; + inset: -1px; + border-radius: 3px; + padding: 1px; + background: linear-gradient(135deg, var(--accent-dim), transparent 40%); + -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + pointer-events: none; +} + +.login-brand { + font-family: var(--font-display); + font-weight: 800; + font-size: 30px; + letter-spacing: 0.5px; + text-transform: uppercase; + margin: 0 0 4px; +} + +.login-sub { + color: var(--fg-dim); + font-size: 12px; + letter-spacing: 0.06em; + margin-bottom: 24px; +} + +.login-error { + color: var(--fail); + font-size: 12px; + margin-top: 10px; + min-height: 16px; +} + +/* ---------- task detail ---------- */ + +.stat-row { + display: flex; + gap: 28px; + flex-wrap: wrap; + margin-bottom: 4px; +} + +.stat { + display: flex; + flex-direction: column; + gap: 2px; +} + +.stat .val { + font-size: 24px; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.stat .lbl { + font-size: 10.5px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--fg-dim); +} + +.stat.ok .val { color: var(--ok); } +.stat.fail .val { color: var(--fail); } +.stat.info .val { color: var(--info); } + +.upload-row { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.file-btn { + position: relative; + overflow: hidden; +} + +.file-btn input[type='file'] { + position: absolute; + inset: 0; + opacity: 0; + cursor: pointer; +} + +.log-pane { + background: var(--bg-inset); + border: 1px solid var(--border); + border-radius: 3px; + padding: 12px 14px; + height: 260px; + overflow-y: auto; + font-size: 12px; + display: flex; + flex-direction: column-reverse; +} + +.log-line { + padding: 3px 0; + border-bottom: 1px dotted rgba(255, 255, 255, 0.04); + display: flex; + gap: 10px; + color: var(--fg-dim); +} + +.log-line .tag { + flex-shrink: 0; + color: var(--accent); + font-weight: 700; +} + +.log-line .payload { + color: var(--fg); + word-break: break-all; +} + +.log-empty { + color: var(--fg-faint); + text-align: center; + margin: auto; +} + +.divider-label { + display: flex; + align-items: center; + gap: 10px; + margin: 22px 0 14px; + color: var(--fg-faint); + font-size: 11px; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.divider-label::before, +.divider-label::after { + content: ''; + flex: 1; + height: 1px; + background: var(--border); +} + +.error-banner { + border: 1px solid var(--fail-dim); + background: rgba(255, 93, 93, 0.08); + color: var(--fail); + padding: 10px 14px; + border-radius: 2px; + font-size: 12px; + margin-bottom: 16px; +} + +.muted-note { + color: var(--fg-faint); + font-size: 12px; +} diff --git a/web/src/components/StatusBadge.tsx b/web/src/components/StatusBadge.tsx new file mode 100644 index 0000000..ae478d8 --- /dev/null +++ b/web/src/components/StatusBadge.tsx @@ -0,0 +1,13 @@ +export function StatusBadge({ status }: { status: string }) { + const s = (status || 'pending').toLowerCase() + let cls = 'badge-pending' + if (s === 'ok' || s === 'done' || s === 'success') cls = 'badge-ok' + else if (s === 'fail' || s === 'failed' || s === 'error') cls = 'badge-fail' + else if (s === 'running' || s === 'testing' || s === 'in_progress') cls = 'badge-info' + return ( + + + {s} + + ) +} diff --git a/web/src/index.css b/web/src/index.css new file mode 100644 index 0000000..c4b1a43 --- /dev/null +++ b/web/src/index.css @@ -0,0 +1,98 @@ +@import '@fontsource/jetbrains-mono/400.css'; +@import '@fontsource/jetbrains-mono/500.css'; +@import '@fontsource/jetbrains-mono/700.css'; +@import '@fontsource/big-shoulders-display/600.css'; +@import '@fontsource/big-shoulders-display/800.css'; + +:root { + --bg: #0a0d0b; + --bg-panel: #0f1512; + --bg-panel-raised: #141b17; + --bg-inset: #070a08; + --border: #23342b; + --border-bright: #3a5443; + --fg: #dbe8de; + --fg-dim: #6f8478; + --fg-faint: #4a5c50; + --accent: #ffb238; + --accent-strong: #ffd27a; + --accent-dim: #7a5a26; + --ok: #52e6a0; + --ok-dim: #234a37; + --fail: #ff5d5d; + --fail-dim: #4a2323; + --pending: #f0c419; + --info: #57c2ff; + + --font-display: 'Big Shoulders Display', 'Arial Narrow', sans-serif; + --font-mono: 'JetBrains Mono', ui-monospace, monospace; + + color-scheme: dark; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; +} + +body { + min-height: 100vh; + background: + radial-gradient(ellipse 80% 60% at 50% -10%, rgba(255, 178, 56, 0.06), transparent), + repeating-linear-gradient( + to bottom, + rgba(255, 255, 255, 0.012) 0px, + rgba(255, 255, 255, 0.012) 1px, + transparent 1px, + transparent 3px + ), + var(--bg); + color: var(--fg); + font-family: var(--font-mono); + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +#root { + min-height: 100vh; +} + +a { + color: inherit; +} + +button { + font-family: var(--font-mono); + cursor: pointer; +} + +input, +select { + font-family: var(--font-mono); +} + +::selection { + background: var(--accent-dim); + color: var(--accent-strong); +} + +::-webkit-scrollbar { + width: 10px; + height: 10px; +} +::-webkit-scrollbar-track { + background: var(--bg-inset); +} +::-webkit-scrollbar-thumb { + background: var(--border-bright); +} + +.mono-num { + font-variant-numeric: tabular-nums; +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..bef5202 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/web/src/pages/Endpoints.tsx b/web/src/pages/Endpoints.tsx new file mode 100644 index 0000000..82c08a9 --- /dev/null +++ b/web/src/pages/Endpoints.tsx @@ -0,0 +1,145 @@ +import { useEffect, useState, type FormEvent } from 'react' +import { createEndpoint, listEndpoints, type Endpoint, type TLSMode } from '../api' + +const emptyForm = { role_label: '', host: '', port: '993', tls_mode: 'ssl' as TLSMode } + +export function Endpoints() { + const [endpoints, setEndpoints] = useState(null) + const [form, setForm] = useState(emptyForm) + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + + function reload() { + listEndpoints() + .then(setEndpoints) + .catch((e) => setError(String(e.message || e))) + } + + useEffect(reload, []) + + async function submit(e: FormEvent) { + e.preventDefault() + setBusy(true) + setError(null) + try { + await createEndpoint({ + role_label: form.role_label, + host: form.host, + port: Number(form.port), + tls_mode: form.tls_mode, + }) + setForm(emptyForm) + reload() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create endpoint') + } finally { + setBusy(false) + } + } + + return ( + <> +
+

+ Endpoints /// mailbox servers +

+
+ +
+
+ Register endpoint +
+
+ + setForm({ ...form, role_label: e.target.value })} + required + /> +
+
+ + setForm({ ...form, host: e.target.value })} + required + /> +
+
+
+ + setForm({ ...form, port: e.target.value })} + required + /> +
+
+ + +
+
+ {error &&
{error}
} +
+ +
+
+
+ +
+ Registered ({endpoints?.length ?? 0}) +
+ + + + + + + + + + + + {endpoints === null ? ( + + + + ) : endpoints.length === 0 ? ( + + + + ) : ( + endpoints.map((ep) => ( + + + + + + + + )) + )} + +
IDRoleHostPortTLS
loading…
no endpoints registered yet
{ep.id}{ep.role_label}{ep.host}{ep.port}{ep.tls_mode}
+
+
+
+ + ) +} diff --git a/web/src/pages/Login.tsx b/web/src/pages/Login.tsx new file mode 100644 index 0000000..197ed92 --- /dev/null +++ b/web/src/pages/Login.tsx @@ -0,0 +1,61 @@ +import { useState, type FormEvent } from 'react' +import { login } from '../api' + +export function Login({ onSuccess }: { onSuccess: () => void }) { + const [user, setUser] = useState('') + const [pass, setPass] = useState('') + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + + async function submit(e: FormEvent) { + e.preventDefault() + if (!user || !pass) return + setBusy(true) + setError(null) + try { + await login(user, pass) + onSuccess() + } catch { + setError('Access denied — check operator id and passphrase.') + } finally { + setBusy(false) + } + } + + return ( +
+
+

+ [IMAP/COPIER] +

+

OPERATOR CONSOLE — AUTHENTICATE TO CONTINUE

+ +
+ + setUser(e.target.value)} + autoComplete="username" + spellCheck={false} + /> +
+
+ + setPass(e.target.value)} + autoComplete="current-password" + /> +
+ +
{error}
+
+
+ ) +} diff --git a/web/src/pages/TaskDetail.tsx b/web/src/pages/TaskDetail.tsx new file mode 100644 index 0000000..788de4f --- /dev/null +++ b/web/src/pages/TaskDetail.tsx @@ -0,0 +1,289 @@ +import { useEffect, useRef, useState, type ChangeEvent, type FormEvent } from 'react' +import { createAccount, getTask, importCSV, runTask, testAccounts, type TaskDetail as TaskDetailData } from '../api' +import { connectTaskWS, type TaskEvent } from '../ws' +import { StatusBadge } from '../components/StatusBadge' + +const emptyAccount = { src_login: '', src_pass: '', dst_login: '', dst_pass: '' } + +export function TaskDetail({ id }: { id: number }) { + const [data, setData] = useState(null) + const [notFound, setNotFound] = useState(false) + const [log, setLog] = useState<{ type: string; text: string }[]>([]) + const [form, setForm] = useState(emptyAccount) + const [busy, setBusy] = useState<'test' | 'run' | 'add' | 'import' | null>(null) + const [error, setError] = useState(null) + const fileInputRef = useRef(null) + + function reload() { + getTask(id) + .then((d) => { + setData(d) + setNotFound(false) + }) + .catch(() => setNotFound(true)) + } + + useEffect(reload, [id]) + + useEffect( + () => + connectTaskWS(id, (ev: TaskEvent) => { + setLog((l) => [{ type: ev.type, text: JSON.stringify(ev.data) }, ...l].slice(0, 300)) + if (['account_started', 'account_test', 'account_done', 'progress', 'run_started', 'run_done', 'error'].includes(ev.type)) { + reload() + } + }), + [id], + ) + + async function submitAccount(e: FormEvent) { + e.preventDefault() + setBusy('add') + setError(null) + try { + await createAccount(id, form) + setForm(emptyAccount) + reload() + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to add account') + } finally { + setBusy(null) + } + } + + async function onFileChosen(e: ChangeEvent) { + const file = e.target.files?.[0] + if (!file) return + setBusy('import') + setError(null) + try { + await importCSV(id, file) + reload() + } catch (err) { + setError(err instanceof Error ? err.message : 'CSV import failed') + } finally { + setBusy(null) + if (fileInputRef.current) fileInputRef.current.value = '' + } + } + + async function onTest() { + setBusy('test') + setError(null) + try { + await testAccounts(id) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to start connection tests') + } finally { + setBusy(null) + } + } + + async function onRun() { + setBusy('run') + setError(null) + try { + await runTask(id) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to start run') + } finally { + setBusy(null) + } + } + + if (notFound) { + return ( +
+

Task #{id} not found.

+ + ← back to tasks + +
+ ) + } + + if (!data) { + return
loading task #{id}…
+ } + + const { task, accounts } = data + const allTested = accounts.length > 0 && accounts.every((a) => a.test_src_status === 'ok' && a.test_dst_status === 'ok') + const totals = accounts.reduce( + (acc, a) => ({ copied: acc.copied + a.copied, skipped: acc.skipped + a.skipped, errors: acc.errors + a.errors }), + { copied: 0, skipped: 0, errors: 0 }, + ) + + return ( + <> +
+
+ + ← all tasks + +

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

+
+ +
+ +
+ Run control +
+
+ {totals.copied} + copied +
+
+ {totals.skipped} + skipped +
+
+ {totals.errors} + errors +
+
+ {accounts.length} + accounts +
+
+ {error &&
{error}
} +
+ + + {!allTested && accounts.length > 0 && run unlocks once every account tests OK on both sides} +
+
+ +
+
+ Add account +
+
+
+ + setForm({ ...form, src_login: e.target.value })} + required + /> +
+
+ + setForm({ ...form, src_pass: e.target.value })} + required + /> +
+
+
+
+ + setForm({ ...form, dst_login: e.target.value })} + required + /> +
+
+ + setForm({ ...form, dst_pass: e.target.value })} + required + /> +
+
+
+ +
+
+ +
or bulk import
+
+ + columns: src_login, src_pass, dst_login, dst_pass +
+
+ +
+ Event log +
+ {log.length === 0 ? ( +
awaiting events over websocket…
+ ) : ( + log.map((l, i) => ( +
+ {l.type} + {l.text} +
+ )) + )} +
+
+
+ +
+ Accounts ({accounts.length}) +
+ + + + + + + + + + + + + + + {accounts.length === 0 ? ( + + + + ) : ( + accounts.map((a) => ( + + + + + + + + + + + )) + )} + +
SourceDestinationSrc testDst testStatusCopiedSkippedErrors
no accounts yet — add one or import a CSV above
{a.src_login}{a.dst_login} + + + + + + {a.copied}{a.skipped}{a.errors}
+
+
+ + ) +} diff --git a/web/src/pages/Tasks.tsx b/web/src/pages/Tasks.tsx new file mode 100644 index 0000000..d534480 --- /dev/null +++ b/web/src/pages/Tasks.tsx @@ -0,0 +1,156 @@ +import { useEffect, useState, type FormEvent } from 'react' +import { createTask, listEndpoints, listTasks, type Endpoint, type Task } from '../api' +import { StatusBadge } from '../components/StatusBadge' + +export function Tasks() { + const [tasks, setTasks] = useState(null) + const [endpoints, setEndpoints] = useState([]) + const [name, setName] = useState('') + const [srcId, setSrcId] = useState('') + const [dstId, setDstId] = useState('') + const [error, setError] = useState(null) + const [busy, setBusy] = useState(false) + + function reload() { + listTasks() + .then(setTasks) + .catch((e: unknown) => setError(e instanceof Error ? e.message : 'Failed to load tasks')) + } + + useEffect(() => { + reload() + listEndpoints().then(setEndpoints).catch(() => {}) + }, []) + + async function submit(e: FormEvent) { + e.preventDefault() + setBusy(true) + setError(null) + try { + const res = await createTask({ + name, + src_endpoint_id: Number(srcId), + dst_endpoint_id: Number(dstId), + }) + setName('') + setSrcId('') + setDstId('') + reload() + location.hash = `#/tasks/${res.id}` + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create task') + } finally { + setBusy(false) + } + } + + const canSubmit = name.trim() !== '' && srcId !== '' && dstId !== '' && srcId !== dstId + + return ( + <> +
+

+ Migration tasks /// mailbox copy jobs +

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

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

+ ) : ( +
+
+ + setName(e.target.value)} placeholder="q3-office365-migration" required /> +
+
+
+ + +
+
+ + +
+
+ {error &&
{error}
} +
+ +
+
+ )} +
+ +
+ All tasks ({tasks?.length ?? 0}) +
+ + + + + + + + + + + {tasks === null ? ( + + + + ) : tasks.length === 0 ? ( + + + + ) : ( + tasks.map((t) => ( + + + + + + + )) + )} + +
IDNameRouteStatus
loading…
no tasks yet — create one above
{t.id} + + {t.name} + + + #{t.src_endpoint_id} → #{t.dst_endpoint_id} + + +
+
+
+ + ) +} diff --git a/web/src/ws.ts b/web/src/ws.ts new file mode 100644 index 0000000..49d2089 --- /dev/null +++ b/web/src/ws.ts @@ -0,0 +1,30 @@ +// Live task event stream. One socket per task-detail view. + +export type TaskEventType = + | 'run_started' + | 'account_started' + | 'account_test' + | 'progress' + | 'account_done' + | 'error' + | 'run_done' + | string + +export interface TaskEvent { + type: TaskEventType + task_id: number + data: unknown +} + +export function connectTaskWS(taskId: number, onEvent: (ev: TaskEvent) => void): () => void { + const proto = location.protocol === 'https:' ? 'wss' : 'ws' + const ws = new WebSocket(`${proto}://${location.host}/ws?task_id=${taskId}`) + ws.onmessage = (m) => { + try { + onEvent(JSON.parse(m.data)) + } catch { + // ignore malformed frames + } + } + return () => ws.close() +} diff --git a/web/tsconfig.app.json b/web/tsconfig.app.json new file mode 100644 index 0000000..6830b6f --- /dev/null +++ b/web/tsconfig.app.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "allowArbitraryExtensions": true, + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json new file mode 100644 index 0000000..8455dcb --- /dev/null +++ b/web/tsconfig.node.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "module": "nodenext", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..367dcca --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + build: { outDir: 'dist' }, + server: { + proxy: { + '/api': 'http://localhost:8080', + '/ws': { target: 'ws://localhost:8080', ws: true }, + }, + }, +}) From 38005c0618f8a04478b12d30d814ac9a3f014e53 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 1 Jul 2026 19:04:46 +0700 Subject: [PATCH 27/30] fix(api): add snake_case json tags to Endpoint/Task/request bodies for frontend contract Go's encoding/json does not bridge snake_case <-> PascalCase field names, so store.Endpoint, store.Task and the anonymous request bodies in accounts.go/auth.go were silently decoding empty/zero values from the frontend's snake_case JSON contract (tls_mode, role_label, src_endpoint_id, dst_endpoint_id, src_login/pass, dst_login/pass). Adds explicit json tags; DB layer is unaffected since pgx binds by positional params, not struct-tag reflection. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01MMHQTtnQtQqL8muAXHr9kd --- internal/httpapi/accounts.go | 5 ++++- internal/httpapi/auth.go | 5 ++++- internal/store/endpoints.go | 10 +++++----- internal/store/json_test.go | 35 +++++++++++++++++++++++++++++++++++ internal/store/tasks.go | 12 ++++++------ 5 files changed, 54 insertions(+), 13 deletions(-) create mode 100644 internal/store/json_test.go diff --git a/internal/httpapi/accounts.go b/internal/httpapi/accounts.go index d86a9a6..09b0788 100644 --- a/internal/httpapi/accounts.go +++ b/internal/httpapi/accounts.go @@ -40,7 +40,10 @@ func (s *Server) handleCreateAccount(w http.ResponseWriter, r *http.Request) { return } var body struct { - SrcLogin, SrcPass, DstLogin, DstPass string + 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) diff --git a/internal/httpapi/auth.go b/internal/httpapi/auth.go index 378e3e7..fd25688 100644 --- a/internal/httpapi/auth.go +++ b/internal/httpapi/auth.go @@ -27,7 +27,10 @@ func NewServer(cfg config.Config, s *store.Store, orch *orchestrator.Orchestrato } func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { - var body struct{ User, Pass string } + 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 diff --git a/internal/store/endpoints.go b/internal/store/endpoints.go index e43a418..aa08f07 100644 --- a/internal/store/endpoints.go +++ b/internal/store/endpoints.go @@ -3,11 +3,11 @@ package store import "context" type Endpoint struct { - ID int64 - RoleLabel string - Host string - Port int - TLSMode string + 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) { diff --git a/internal/store/json_test.go b/internal/store/json_test.go new file mode 100644 index 0000000..d050f69 --- /dev/null +++ b/internal/store/json_test.go @@ -0,0 +1,35 @@ +package store + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestEndpointJSONRoundTrip(t *testing.T) { + var e Endpoint + if err := json.Unmarshal([]byte(`{"role_label":"src","host":"h","port":993,"tls_mode":"ssl"}`), &e); err != nil { + t.Fatal(err) + } + if e.RoleLabel != "src" || e.Host != "h" || e.Port != 993 || e.TLSMode != "ssl" { + t.Fatalf("decode failed: %+v", e) + } + b, _ := json.Marshal(e) + if !strings.Contains(string(b), `"tls_mode":"ssl"`) || !strings.Contains(string(b), `"role_label":"src"`) { + t.Fatalf("marshal not snake_case: %s", b) + } +} + +func TestTaskJSONRoundTrip(t *testing.T) { + var tk Task + if err := json.Unmarshal([]byte(`{"name":"n","src_endpoint_id":1,"dst_endpoint_id":2}`), &tk); err != nil { + t.Fatal(err) + } + if tk.Name != "n" || tk.SrcEndpointID != 1 || tk.DstEndpointID != 2 { + t.Fatalf("decode failed: %+v", tk) + } + b, _ := json.Marshal(tk) + if !strings.Contains(string(b), `"src_endpoint_id":1`) { + t.Fatalf("marshal not snake_case: %s", b) + } +} diff --git a/internal/store/tasks.go b/internal/store/tasks.go index 2d48fea..c452c13 100644 --- a/internal/store/tasks.go +++ b/internal/store/tasks.go @@ -3,12 +3,12 @@ package store import "context" type Task struct { - ID int64 - Name string - SrcEndpointID int64 - DstEndpointID int64 - Status string - FolderMapping map[string]string + 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) { From 1373aa0a77806de533f958b2aec265acb831fda4 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 1 Jul 2026 19:16:38 +0700 Subject: [PATCH 28/30] feat(deploy): docker image, caddy, compose, e2e script Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01MMHQTtnQtQqL8muAXHr9kd --- .dockerignore | 17 +++ .env.example | 11 ++ .gitignore | 8 ++ Caddyfile | 5 + Caddyfile.tls | 11 ++ Dockerfile | 29 +++++ Makefile | 22 ++++ README.md | 89 +++++++++++++++ docker-compose.tls.yml | 9 ++ docker-compose.yml | 47 ++++++++ scripts/docker-compose.e2e.yml | 21 ++++ scripts/e2e.sh | 200 +++++++++++++++++++++++++++++++++ 12 files changed, 469 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Caddyfile create mode 100644 Caddyfile.tls create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 README.md create mode 100644 docker-compose.tls.yml create mode 100644 docker-compose.yml create mode 100644 scripts/docker-compose.e2e.yml create mode 100755 scripts/e2e.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a08fbbd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +.git +.gitignore +.superpowers +.claude +swarm-report +*.md +!README.md + +web/node_modules +web/dist + +**/*.log +.env +.env.* +!.env.example + +.DS_Store diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9b04974 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +POSTGRES_PASSWORD=change-me +AUTH_USER=admin +AUTH_PASS=change-me +# 32 bytes base64-encoded: openssl rand -base64 32 +ENC_KEY= +SESSION_SECRET=change-me-long-random +WORKER_CONCURRENCY=4 +HTTP_PORT=80 +# For HTTPS + Let's Encrypt (docker-compose.tls.yml override): set a real domain and email +DOMAIN= +ACME_EMAIL= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f28b0e5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.env +.env.* +!.env.example + +/internal/httpapi/webdist/* +!/internal/httpapi/webdist/index.html + +.DS_Store diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..f56e6ea --- /dev/null +++ b/Caddyfile @@ -0,0 +1,5 @@ +# Default profile: plain HTTP on :80, no TLS termination. +# Used by: docker compose up -d +:80 { + reverse_proxy app:8080 +} diff --git a/Caddyfile.tls b/Caddyfile.tls new file mode 100644 index 0000000..bb846ce --- /dev/null +++ b/Caddyfile.tls @@ -0,0 +1,11 @@ +# HTTPS profile: automatic Let's Encrypt certificate for $DOMAIN. +# Used by: docker compose -f docker-compose.yml -f docker-compose.tls.yml up -d +# Requires DOMAIN (and ACME_EMAIL) set in .env, and the server's :80/:443 +# reachable from the internet for the ACME HTTP-01 challenge. +{ + email {$ACME_EMAIL} +} + +{$DOMAIN} { + reverse_proxy app:8080 +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c076d39 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +# syntax=docker/dockerfile:1 + +# 1) build web (React/Vite SPA) +FROM node:22-alpine AS web +WORKDIR /web +COPY web/package.json web/package-lock.json ./ +RUN npm ci +COPY web/ ./ +RUN npm run build + +# 2) build go (embed web build via internal/httpapi/webdist) +FROM golang:1.24-alpine AS go +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +# overwrite the committed webdist stub with the real SPA build +RUN rm -rf ./internal/httpapi/webdist +COPY --from=web /web/dist ./internal/httpapi/webdist +RUN CGO_ENABLED=0 go build -trimpath -o /out/server ./cmd/server + +# 3) runtime +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates +WORKDIR /app +COPY --from=go /out/server /app/server +COPY migrations /app/migrations +EXPOSE 8080 +ENTRYPOINT ["/app/server"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a17262f --- /dev/null +++ b/Makefile @@ -0,0 +1,22 @@ +.PHONY: build up up-tls down logs test e2e + +build: + docker compose build + +up: + docker compose up -d + +up-tls: + docker compose -f docker-compose.yml -f docker-compose.tls.yml up -d + +down: + docker compose down + +logs: + docker compose logs -f + +test: + go test ./... + +e2e: + bash scripts/e2e.sh diff --git a/README.md b/README.md new file mode 100644 index 0000000..1a6ad2e --- /dev/null +++ b/README.md @@ -0,0 +1,89 @@ +# 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 +``` + +## Architecture + +- `cmd/server` — entrypoint: runs DB migrations, then serves HTTP. +- `internal/httpapi` — REST API + WebSocket + embedded SPA (`webdist/`). +- `internal/orchestrator` — test/run coordination, per-account concurrency. +- `internal/imapx` — IMAP connect/list/copy primitives. +- `internal/store` — Postgres access (endpoints, tasks, accounts, runs). +- `web/` — React/Vite SPA, built into `internal/httpapi/webdist` at image + build time (see `Dockerfile`). diff --git a/docker-compose.tls.yml b/docker-compose.tls.yml new file mode 100644 index 0000000..9effd73 --- /dev/null +++ b/docker-compose.tls.yml @@ -0,0 +1,9 @@ +# Override for the HTTPS/Let's Encrypt path. +# Usage: docker compose -f docker-compose.yml -f docker-compose.tls.yml up -d +# Requires DOMAIN and ACME_EMAIL to be set in .env. +services: + caddy: + volumes: + - ./Caddyfile.tls:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..bcfdf36 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,47 @@ +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: imap + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: imapcopier + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U imap"] + interval: 5s + timeout: 3s + retries: 5 + + app: + build: . + environment: + DATABASE_URL: postgres://imap:${POSTGRES_PASSWORD}@postgres:5432/imapcopier?sslmode=disable + AUTH_USER: ${AUTH_USER} + AUTH_PASS: ${AUTH_PASS} + ENC_KEY: ${ENC_KEY} + SESSION_SECRET: ${SESSION_SECRET} + WORKER_CONCURRENCY: ${WORKER_CONCURRENCY:-4} + depends_on: + postgres: + condition: service_healthy + + caddy: + image: caddy:2-alpine + ports: + - "${HTTP_PORT:-80}:80" + - "443:443" + environment: + DOMAIN: ${DOMAIN:-} + ACME_EMAIL: ${ACME_EMAIL:-} + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + depends_on: + - app + +volumes: + pgdata: + caddy_data: + caddy_config: diff --git a/scripts/docker-compose.e2e.yml b/scripts/docker-compose.e2e.yml new file mode 100644 index 0000000..f429116 --- /dev/null +++ b/scripts/docker-compose.e2e.yml @@ -0,0 +1,21 @@ +# Override used only by scripts/e2e.sh: adds a greenmail IMAP server that +# acts as BOTH the source and the destination (two mailboxes, one server), +# reachable from the app container via compose DNS as host "greenmail". +services: + greenmail: + image: greenmail/standalone:latest + environment: + GREENMAIL_OPTS: >- + -Dgreenmail.setup.test.all + -Dgreenmail.hostname=0.0.0.0 + -Dgreenmail.auth.disabled + -Dgreenmail.verbose + ports: + # exposed to the host so the seeding script (running outside docker) + # can APPEND test messages into the source mailbox + - "3143:3143" + + app: + depends_on: + greenmail: + condition: service_started diff --git a/scripts/e2e.sh b/scripts/e2e.sh new file mode 100755 index 0000000..143b1c6 --- /dev/null +++ b/scripts/e2e.sh @@ -0,0 +1,200 @@ +#!/usr/bin/env bash +# End-to-end test against the full docker-compose stack: +# postgres + app (this repo's image) + caddy + a greenmail server that plays +# BOTH the source and destination IMAP endpoint (two mailboxes on one host). +# +# Drives the real REST API: login -> create endpoints -> create task -> +# add account -> /test -> poll -> /run -> poll -> assert copied>0. +# Then runs /run a SECOND time and asserts it copies nothing new (idempotency +# via Message-ID dedup), proving the full stack end-to-end. +# +# Usage: bash scripts/e2e.sh +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +PROJECT="imapcopier-e2e" +ENV_FILE="$ROOT_DIR/.env.e2e" +COMPOSE=(docker compose -p "$PROJECT" --env-file "$ENV_FILE" -f docker-compose.yml -f scripts/docker-compose.e2e.yml) + +HTTP_PORT=8089 +BASE="http://localhost:${HTTP_PORT}" +AUTH_USER="e2e" +AUTH_PASS="e2e-$(openssl rand -hex 8)" +COOKIE_JAR="$(mktemp -t imapcopier-e2e-cookies.XXXXXX)" +SEED_PY="$(mktemp -t imapcopier-e2e-seed.XXXXXX.py)" + +SRC_USER="src1@example.com" +DST_USER="dst1@example.com" +MAIL_PASS="anypass" + +log() { echo "[e2e] $*"; } +fail() { echo "[e2e] FAIL: $*"; exit 1; } + +cleanup() { + log "cleaning up (containers, volumes, temp files)" + "${COMPOSE[@]}" down -v --remove-orphans >/dev/null 2>&1 || true + rm -f "$ENV_FILE" "$COOKIE_JAR" "$SEED_PY" +} +trap cleanup EXIT + +log "writing ephemeral env file $ENV_FILE" +cat > "$ENV_FILE" </dev/null 2>&1 || true + +log "building and starting stack (postgres, app, caddy, greenmail)" +"${COMPOSE[@]}" up -d --build + +wait_for() { + local desc="$1" tries="$2"; shift 2 + for ((i = 1; i <= tries; i++)); do + if "$@" >/dev/null 2>&1; then + log "$desc: ready" + return 0 + fi + sleep 1 + done + fail "$desc: timed out after ${tries}s" +} + +log "waiting for app healthz" +wait_for "app /healthz" 60 curl -fsS "$BASE/healthz" + +log "waiting for greenmail IMAP port" +wait_for "greenmail:3143" 30 bash -c "exec 3<>/dev/tcp/127.0.0.1/3143" + +log "seeding two messages into ${SRC_USER} INBOX" +cat > "$SEED_PY" <<'PYEOF' +import imaplib, sys, time + +host, port, user, password, count = sys.argv[1], int(sys.argv[2]), sys.argv[3], sys.argv[4], int(sys.argv[5]) +m = imaplib.IMAP4(host, port) +m.login(user, password) +m.select("INBOX") +for i in range(count): + msg = ( + f"Message-ID: \r\n" + f"From: sender@example.com\r\n" + f"To: {user}\r\n" + f"Subject: e2e test message {i}\r\n" + f"\r\n" + f"body {i}\r\n" + ).encode() + typ, data = m.append("INBOX", None, imaplib.Time2Internaldate(time.time()), msg) + if typ != "OK": + print(f"append failed: {typ} {data}", file=sys.stderr) + sys.exit(1) +m.logout() +print("seeded", count, "messages") +PYEOF +python3 "$SEED_PY" 127.0.0.1 3143 "$SRC_USER" "$MAIL_PASS" 2 + +api() { + local method="$1" path="$2" data="${3:-}" + if [[ -n "$data" ]]; then + curl -fsS -b "$COOKIE_JAR" -c "$COOKIE_JAR" -X "$method" "$BASE$path" \ + -H 'Content-Type: application/json' -d "$data" + else + curl -fsS -b "$COOKIE_JAR" -c "$COOKIE_JAR" -X "$method" "$BASE$path" + fi +} + +log "logging in as ${AUTH_USER}" +curl -fsS -c "$COOKIE_JAR" -X POST "$BASE/api/login" \ + -H 'Content-Type: application/json' \ + -d "{\"user\":\"${AUTH_USER}\",\"pass\":\"${AUTH_PASS}\"}" >/dev/null + +log "creating src/dst endpoints (both point at greenmail:3143)" +SRC_EP_ID=$(api POST /api/endpoints '{"role_label":"src","host":"greenmail","port":3143,"tls_mode":"plain"}' | jq -r .id) +DST_EP_ID=$(api POST /api/endpoints '{"role_label":"dst","host":"greenmail","port":3143,"tls_mode":"plain"}' | jq -r .id) +[[ "$SRC_EP_ID" =~ ^[0-9]+$ ]] || fail "bad src endpoint id: $SRC_EP_ID" +[[ "$DST_EP_ID" =~ ^[0-9]+$ ]] || fail "bad dst endpoint id: $DST_EP_ID" +log "src_endpoint_id=$SRC_EP_ID dst_endpoint_id=$DST_EP_ID" + +log "creating task" +TASK_ID=$(api POST /api/tasks "{\"name\":\"e2e\",\"src_endpoint_id\":${SRC_EP_ID},\"dst_endpoint_id\":${DST_EP_ID},\"folder_mapping\":{}}" | jq -r .id) +[[ "$TASK_ID" =~ ^[0-9]+$ ]] || fail "bad task id: $TASK_ID" +log "task_id=$TASK_ID" + +log "adding account (src -> dst)" +ACCOUNT_ID=$(api POST "/api/tasks/${TASK_ID}/accounts" \ + "{\"src_login\":\"${SRC_USER}\",\"src_pass\":\"${MAIL_PASS}\",\"dst_login\":\"${DST_USER}\",\"dst_pass\":\"${MAIL_PASS}\"}" | jq -r .id) +[[ "$ACCOUNT_ID" =~ ^[0-9]+$ ]] || fail "bad account id: $ACCOUNT_ID" +log "account_id=$ACCOUNT_ID" + +log "POST /test" +api POST "/api/tasks/${TASK_ID}/test" >/dev/null + +wait_test_ok() { + for ((i = 1; i <= 30; i++)); do + local res src dst + res=$(api GET "/api/tasks/${TASK_ID}") + src=$(echo "$res" | jq -r '.accounts[0].test_src_status') + dst=$(echo "$res" | jq -r '.accounts[0].test_dst_status') + if [[ "$src" == "ok" && "$dst" == "ok" ]]; then + log "connection tests: src=$src dst=$dst" + return 0 + fi + if [[ "$src" == "fail" || "$dst" == "fail" ]]; then + fail "connection test failed: src=$src dst=$dst" + fi + sleep 1 + done + fail "connection tests did not complete in time" +} +wait_test_ok + +wait_run_done() { + for ((i = 1; i <= 60; i++)); do + local status + status=$(api GET "/api/tasks/${TASK_ID}" | jq -r '.task.status') + if [[ "$status" == "done" ]]; then + return 0 + fi + sleep 1 + done + fail "run did not finish in time (last status=$status)" +} + +log "POST /run (first run)" +api POST "/api/tasks/${TASK_ID}/run" >/dev/null +wait_run_done + +RES1=$(api GET "/api/tasks/${TASK_ID}") +RUN1_COPIED=$(echo "$RES1" | jq -r '.accounts[0].copied') +RUN1_SKIPPED=$(echo "$RES1" | jq -r '.accounts[0].skipped') +RUN1_ERRORS=$(echo "$RES1" | jq -r '.accounts[0].errors') +log "run 1: copied=$RUN1_COPIED skipped=$RUN1_SKIPPED errors=$RUN1_ERRORS" +[[ "$RUN1_ERRORS" == "0" ]] || fail "run 1 had errors" +[[ "$RUN1_COPIED" -gt 0 ]] || fail "run 1 copied nothing (expected >0)" + +log "POST /run (second run, expect idempotency)" +api POST "/api/tasks/${TASK_ID}/run" >/dev/null +wait_run_done + +RES2=$(api GET "/api/tasks/${TASK_ID}") +RUN2_COPIED_TOTAL=$(echo "$RES2" | jq -r '.accounts[0].copied') +RUN2_SKIPPED_TOTAL=$(echo "$RES2" | jq -r '.accounts[0].skipped') +RUN2_ERRORS=$(echo "$RES2" | jq -r '.accounts[0].errors') +RUN2_COPIED_DELTA=$((RUN2_COPIED_TOTAL - RUN1_COPIED)) +RUN2_SKIPPED_DELTA=$((RUN2_SKIPPED_TOTAL - RUN1_SKIPPED)) +log "run 2: copied_delta=$RUN2_COPIED_DELTA skipped_delta=$RUN2_SKIPPED_DELTA errors=$RUN2_ERRORS" + +[[ "$RUN2_ERRORS" == "0" ]] || fail "run 2 had errors" +[[ "$RUN2_COPIED_DELTA" -eq 0 ]] || fail "run 2 copied $RUN2_COPIED_DELTA new messages (expected 0, not idempotent)" +[[ "$RUN2_SKIPPED_DELTA" -gt 0 ]] || fail "run 2 skipped delta is $RUN2_SKIPPED_DELTA (expected >0)" + +log "PASS: run1 copied=$RUN1_COPIED skipped=$RUN1_SKIPPED; run2 copied=$RUN2_COPIED_DELTA skipped=$RUN2_SKIPPED_DELTA (idempotent)" From 2429c786e44d7736a6aa0680721bfd8df95cb9b5 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 1 Jul 2026 19:32:04 +0700 Subject: [PATCH 29/30] fix(orchestrator): prevent concurrent double-run duplicating messages; reflect errors in status --- README.md | 8 +++++++ internal/httpapi/run.go | 4 ++++ internal/orchestrator/orchestrator.go | 19 +++++++++++++--- internal/store/tasks.go | 11 +++++++++ internal/store/tasks_test.go | 32 +++++++++++++++++++++++++++ web/src/pages/TaskDetail.tsx | 2 +- 6 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 internal/store/tasks_test.go diff --git a/README.md b/README.md index 1a6ad2e..bbdd6be 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,14 @@ 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. diff --git a/internal/httpapi/run.go b/internal/httpapi/run.go index bbedaab..b9b689a 100644 --- a/internal/httpapi/run.go +++ b/internal/httpapi/run.go @@ -74,6 +74,10 @@ func (s *Server) handleRun(w http.ResponseWriter, r *http.Request) { 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 diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index efbebdd..b693a5a 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -13,6 +13,7 @@ import ( ) var ErrNotTested = errors.New("accounts not fully tested") +var ErrAlreadyRunning = errors.New("task already running") type Orchestrator struct { store *store.Store @@ -99,15 +100,23 @@ func (o *Orchestrator) Run(ctx context.Context, taskID int64) (int64, error) { 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.store.SetTaskStatus(ctx, taskID, "running") 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) @@ -138,8 +147,12 @@ func (o *Orchestrator) runAll(ctx context.Context, task store.Task, runID int64, } wg.Wait() - _ = o.store.FinishRun(ctx, runID, "done", totCopied, totSkipped, totErr) - _ = o.store.SetTaskStatus(ctx, task.ID, "done") + 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}}) } diff --git a/internal/store/tasks.go b/internal/store/tasks.go index c452c13..f4b13d3 100644 --- a/internal/store/tasks.go +++ b/internal/store/tasks.go @@ -55,3 +55,14 @@ func (s *Store) SetTaskStatus(ctx context.Context, id int64, status string) erro _, err := s.Pool.Exec(ctx, `UPDATE tasks SET status=$2 WHERE id=$1`, id, status) return err } + +// TryMarkTaskRunning atomically sets status='running' only if the task is not already running. +// Returns true if this call acquired the run (status was not 'running' before), false otherwise. +func (s *Store) TryMarkTaskRunning(ctx context.Context, id int64) (bool, error) { + ct, err := s.Pool.Exec(ctx, + `UPDATE tasks SET status='running' WHERE id=$1 AND status<>'running'`, id) + if err != nil { + return false, err + } + return ct.RowsAffected() == 1, nil +} diff --git a/internal/store/tasks_test.go b/internal/store/tasks_test.go new file mode 100644 index 0000000..78346b0 --- /dev/null +++ b/internal/store/tasks_test.go @@ -0,0 +1,32 @@ +package store + +import ( + "context" + "testing" +) + +func TestTryMarkTaskRunningIsExclusive(t *testing.T) { + s := testStore(t) + ctx := context.Background() + epSrc, _ := s.CreateEndpoint(ctx, Endpoint{RoleLabel: "src", Host: "a", Port: 993, TLSMode: "ssl"}) + epDst, _ := s.CreateEndpoint(ctx, Endpoint{RoleLabel: "dst", Host: "b", Port: 993, TLSMode: "ssl"}) + taskID, _ := s.CreateTask(ctx, Task{Name: "t", SrcEndpointID: epSrc, DstEndpointID: epDst}) + + first, err := s.TryMarkTaskRunning(ctx, taskID) + if err != nil || !first { + t.Fatalf("first acquire must succeed: ok=%v err=%v", first, err) + } + second, err := s.TryMarkTaskRunning(ctx, taskID) + if err != nil { + t.Fatalf("err: %v", err) + } + if second { + t.Fatal("second acquire must fail while running") + } + // after completion, a re-run may acquire again + _ = s.SetTaskStatus(ctx, taskID, "done") + third, _ := s.TryMarkTaskRunning(ctx, taskID) + if !third { + t.Fatal("acquire after completion must succeed") + } +} diff --git a/web/src/pages/TaskDetail.tsx b/web/src/pages/TaskDetail.tsx index 788de4f..074fd41 100644 --- a/web/src/pages/TaskDetail.tsx +++ b/web/src/pages/TaskDetail.tsx @@ -152,7 +152,7 @@ export function TaskDetail({ id }: { id: number }) { - {!allTested && accounts.length > 0 && run unlocks once every account tests OK on both sides} From 8af7b117913360613a71f8a61ef20decb82d9b84 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 1 Jul 2026 19:36:40 +0700 Subject: [PATCH 30/30] fix(orchestrator): recover from panics in run goroutines to avoid process crash and stuck 'running' task Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01MMHQTtnQtQqL8muAXHr9kd --- internal/orchestrator/orchestrator.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index b693a5a..5372f91 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -124,6 +124,14 @@ func (o *Orchestrator) Run(ctx context.Context, taskID int64) (int64, error) { } 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 @@ -137,6 +145,17 @@ func (o *Orchestrator) runAll(ctx context.Context, task store.Task, runID int64, 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