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) + } +}