From cc9d54afdcc598f2c89df0b880f71e70659cfafc Mon Sep 17 00:00:00 2001 From: thuanle Date: Mon, 27 Apr 2026 19:02:31 +0700 Subject: [PATCH 1/2] fix callback URL to use configurable public base Separate callback URL generation from LISTEN_ADDR by introducing CALLBACK_BASE_URL with a localhost default for local development. Co-Authored-By: Claude Opus 4.7 --- .env.example | 1 + README.md | 3 +- internal/ai_client/client.go | 11 ++++++- internal/ai_client/client_test.go | 43 ++++++++++++++++++++++++++ internal/config/config.go | 7 +++++ internal/config/config_test.go | 50 +++++++++++++++++++++++++++++++ 6 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 internal/config/config_test.go diff --git a/.env.example b/.env.example index 5a88668..019eea9 100644 --- a/.env.example +++ b/.env.example @@ -22,3 +22,4 @@ WHITELIST_EMAILS=user1@example.com,user2@example.com # === Optional === IMAP_PROXY_URL= LISTEN_ADDR=:8080 +CALLBACK_BASE_URL=http://localhost diff --git a/README.md b/README.md index 3ca5bd9..07837e9 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,8 @@ docker logs -f claw-email-bridge | `SYSTEM_EMAIL` | ✅ | Email của hệ thống (dùng cho anti-loop) | | `WHITELIST_EMAILS` | | Danh sách email được phép, phân cách bằng dấu phẩy | | `IMAP_PROXY_URL` | | SOCKS5 proxy cho IMAP (vd: `socks5://user:pass@host:port`) | -| `LISTEN_ADDR` | | Địa chỉ HTTP server (mặc định `:8080`) | +| `LISTEN_ADDR` | | Địa chỉ bind HTTP server cục bộ (mặc định `:8080`) | +| `CALLBACK_BASE_URL` | | Base URL public để tạo `callback_url` gửi OpenClaw (mặc định `http://localhost`) | ## Health Checks diff --git a/internal/ai_client/client.go b/internal/ai_client/client.go index 95c7f81..b4e82b3 100644 --- a/internal/ai_client/client.go +++ b/internal/ai_client/client.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "strings" "time" "thuanle.me/claw-email-bridge/internal/config" @@ -65,7 +66,7 @@ func (d *Dispatcher) Dispatch(task *database.Task) { } // Build callback URL. - callbackURL := fmt.Sprintf("http://%s/callback", d.cfg.ListenAddr) + callbackURL := buildCallbackURL(d.cfg.CallbackBaseURL) payload := openClawRequest{ Input: task.BodyPlain, @@ -129,6 +130,14 @@ func (d *Dispatcher) Dispatch(task *database.Task) { } } +func buildCallbackURL(base string) string { + trimmed := strings.TrimRight(base, "/") + if trimmed == "" { + trimmed = "http://localhost" + } + return trimmed + "/callback" +} + // callAPI makes a single HTTP POST to OpenClaw. func (d *Dispatcher) callAPI(payload openClawRequest) error { body, err := json.Marshal(payload) diff --git a/internal/ai_client/client_test.go b/internal/ai_client/client_test.go index 77b8643..2a11683 100644 --- a/internal/ai_client/client_test.go +++ b/internal/ai_client/client_test.go @@ -1,6 +1,7 @@ package ai_client_test import ( + "encoding/json" "net/http" "net/http/httptest" "sync/atomic" @@ -112,3 +113,45 @@ func TestDispatch_AllRetriesFailed(t *testing.T) { t.Errorf("expected attempt_openclaw = 3, got %d", updated.AttemptOpenClaw) } } + +func TestDispatch_UsesCallbackBaseURL(t *testing.T) { + tdb := database.NewTestDB(t) + + var callbackURL string + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var payload map[string]any + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("decode payload: %v", err) + } + val, _ := payload["callback_url"].(string) + callbackURL = val + w.WriteHeader(http.StatusOK) + })) + defer mockServer.Close() + + cfg := &config.Config{ + OpenClawURL: mockServer.URL, + ListenAddr: ":9999", + CallbackBaseURL: "https://bridge.example.com/", + } + + task := &database.Task{ + TaskUUID: "uuid-callback-url", + ThreadID: "thread-1", + MessageID: "msg-callback@test.com", + Sender: "user@test.com", + Subject: "Callback test", + BodyPlain: "Hello", + Status: database.StatusReceived, + } + if err := database.CreateTask(tdb.DB, task); err != nil { + t.Fatalf("create task: %v", err) + } + + dispatcher := ai_client.NewDispatcher(cfg, tdb.DB) + dispatcher.Dispatch(task) + + if callbackURL != "https://bridge.example.com/callback" { + t.Fatalf("expected normalized callback URL, got %q", callbackURL) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 6c8197d..881a4b6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -36,6 +36,9 @@ type Config struct { // Server ListenAddr string + + // Public callback base URL for OpenClaw callbacks + CallbackBaseURL string } // Load reads .env (if present) and populates Config from environment variables. @@ -59,6 +62,7 @@ func Load() (*Config, error) { SystemEmail: os.Getenv("SYSTEM_EMAIL"), IMAPProxyURL: os.Getenv("IMAP_PROXY_URL"), ListenAddr: os.Getenv("LISTEN_ADDR"), + CallbackBaseURL: os.Getenv("CALLBACK_BASE_URL"), } if raw := os.Getenv("WHITELIST_EMAILS"); raw != "" { @@ -72,6 +76,9 @@ func Load() (*Config, error) { if cfg.ListenAddr == "" { cfg.ListenAddr = ":8080" } + if cfg.CallbackBaseURL == "" { + cfg.CallbackBaseURL = "http://localhost" + } if err := cfg.validate(); err != nil { return nil, err diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..0d8b6c8 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,50 @@ +package config + +import "testing" + +func setRequiredEnv(t *testing.T) { + t.Helper() + t.Setenv("IMAP_HOST", "imap.example.com") + t.Setenv("IMAP_PORT", "993") + t.Setenv("IMAP_USER", "u") + t.Setenv("IMAP_PASS", "p") + t.Setenv("SMTP_HOST", "smtp.example.com") + t.Setenv("SMTP_PORT", "587") + t.Setenv("SMTP_USER", "u") + t.Setenv("SMTP_PASS", "p") + t.Setenv("OPENCLAW_URL", "http://openclaw.local") + t.Setenv("BRIDGE_CALLBACK_TOKEN", "token") + t.Setenv("SYSTEM_EMAIL", "system@example.com") + t.Setenv("LISTEN_ADDR", ":8080") + t.Setenv("WHITELIST_EMAILS", "") + t.Setenv("IMAP_PROXY_URL", "") + t.Setenv("OPENCLAW_API_KEY", "") +} + +func TestLoad_DefaultCallbackBaseURL_WhenUnset(t *testing.T) { + setRequiredEnv(t) + t.Setenv("CALLBACK_BASE_URL", "") + + cfg, err := Load() + if err != nil { + t.Fatalf("load config: %v", err) + } + + if cfg.CallbackBaseURL != "http://localhost" { + t.Fatalf("expected default callback base URL http://localhost, got %q", cfg.CallbackBaseURL) + } +} + +func TestLoad_UsesCallbackBaseURL_WhenSet(t *testing.T) { + setRequiredEnv(t) + t.Setenv("CALLBACK_BASE_URL", "https://bridge.example.com") + + cfg, err := Load() + if err != nil { + t.Fatalf("load config: %v", err) + } + + if cfg.CallbackBaseURL != "https://bridge.example.com" { + t.Fatalf("expected callback base URL from env, got %q", cfg.CallbackBaseURL) + } +} From 2168042edb95c504ebc1f59fbd17839379c47533 Mon Sep 17 00:00:00 2001 From: thuanle Date: Mon, 27 Apr 2026 20:56:47 +0700 Subject: [PATCH 2/2] fix default callback base URL port mismatch Derive CALLBACK_BASE_URL fallback from LISTEN_ADDR so default local setup resolves to localhost:8080 and stays aligned with server bind port. Co-Authored-By: Claude Opus 4.7 --- .env.example | 2 +- README.md | 2 +- internal/ai_client/client.go | 2 +- internal/config/config.go | 14 +++++++++++++- internal/config/config_test.go | 19 +++++++++++++++++-- 5 files changed, 33 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index 019eea9..71cbc60 100644 --- a/.env.example +++ b/.env.example @@ -22,4 +22,4 @@ WHITELIST_EMAILS=user1@example.com,user2@example.com # === Optional === IMAP_PROXY_URL= LISTEN_ADDR=:8080 -CALLBACK_BASE_URL=http://localhost +CALLBACK_BASE_URL=http://localhost:8080 diff --git a/README.md b/README.md index 07837e9..014ad0c 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ docker logs -f claw-email-bridge | `WHITELIST_EMAILS` | | Danh sách email được phép, phân cách bằng dấu phẩy | | `IMAP_PROXY_URL` | | SOCKS5 proxy cho IMAP (vd: `socks5://user:pass@host:port`) | | `LISTEN_ADDR` | | Địa chỉ bind HTTP server cục bộ (mặc định `:8080`) | -| `CALLBACK_BASE_URL` | | Base URL public để tạo `callback_url` gửi OpenClaw (mặc định `http://localhost`) | +| `CALLBACK_BASE_URL` | | Base URL public để tạo `callback_url` gửi OpenClaw (mặc định theo `LISTEN_ADDR`, ví dụ `http://localhost:8080`) | ## Health Checks diff --git a/internal/ai_client/client.go b/internal/ai_client/client.go index b4e82b3..1e1705c 100644 --- a/internal/ai_client/client.go +++ b/internal/ai_client/client.go @@ -133,7 +133,7 @@ func (d *Dispatcher) Dispatch(task *database.Task) { func buildCallbackURL(base string) string { trimmed := strings.TrimRight(base, "/") if trimmed == "" { - trimmed = "http://localhost" + trimmed = "http://localhost:8080" } return trimmed + "/callback" } diff --git a/internal/config/config.go b/internal/config/config.go index 881a4b6..7cbe3d1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,6 +2,7 @@ package config import ( "fmt" + "net" "os" "strings" @@ -77,7 +78,7 @@ func Load() (*Config, error) { cfg.ListenAddr = ":8080" } if cfg.CallbackBaseURL == "" { - cfg.CallbackBaseURL = "http://localhost" + cfg.CallbackBaseURL = defaultCallbackBaseURL(cfg.ListenAddr) } if err := cfg.validate(); err != nil { @@ -87,6 +88,17 @@ func Load() (*Config, error) { return cfg, nil } +func defaultCallbackBaseURL(listenAddr string) string { + host, port, err := net.SplitHostPort(listenAddr) + if err != nil || port == "" { + return "http://localhost:8080" + } + if host == "" || host == "0.0.0.0" || host == "::" { + host = "localhost" + } + return fmt.Sprintf("http://%s:%s", host, port) +} + // validate checks that all required configuration values are present. func (c *Config) validate() error { required := map[string]string{ diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 0d8b6c8..dd702a2 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -30,8 +30,23 @@ func TestLoad_DefaultCallbackBaseURL_WhenUnset(t *testing.T) { t.Fatalf("load config: %v", err) } - if cfg.CallbackBaseURL != "http://localhost" { - t.Fatalf("expected default callback base URL http://localhost, got %q", cfg.CallbackBaseURL) + if cfg.CallbackBaseURL != "http://localhost:8080" { + t.Fatalf("expected default callback base URL http://localhost:8080, got %q", cfg.CallbackBaseURL) + } +} + +func TestLoad_DefaultCallbackBaseURL_UsesListenPort(t *testing.T) { + setRequiredEnv(t) + t.Setenv("CALLBACK_BASE_URL", "") + t.Setenv("LISTEN_ADDR", ":9090") + + cfg, err := Load() + if err != nil { + t.Fatalf("load config: %v", err) + } + + if cfg.CallbackBaseURL != "http://localhost:9090" { + t.Fatalf("expected callback base URL to follow LISTEN_ADDR port, got %q", cfg.CallbackBaseURL) } }