Merge pull request 'Fix callback URL to use configurable public base' (#6) from fix/callback-base-url into main
Reviewed-on: #6 Reviewed-by: codex <39+codex@noreply.localhost>
This commit was merged in pull request #6.
This commit is contained in:
@@ -22,3 +22,4 @@ WHITELIST_EMAILS=user1@example.com,user2@example.com
|
|||||||
# === Optional ===
|
# === Optional ===
|
||||||
IMAP_PROXY_URL=
|
IMAP_PROXY_URL=
|
||||||
LISTEN_ADDR=:8080
|
LISTEN_ADDR=:8080
|
||||||
|
CALLBACK_BASE_URL=http://localhost:8080
|
||||||
|
|||||||
@@ -73,7 +73,8 @@ docker logs -f claw-email-bridge
|
|||||||
| `SYSTEM_EMAIL` | ✅ | Email của hệ thống (dùng cho anti-loop) |
|
| `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 |
|
| `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`) |
|
| `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 theo `LISTEN_ADDR`, ví dụ `http://localhost:8080`) |
|
||||||
|
|
||||||
## Health Checks
|
## Health Checks
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"thuanle.me/claw-email-bridge/internal/config"
|
"thuanle.me/claw-email-bridge/internal/config"
|
||||||
@@ -65,7 +66,7 @@ func (d *Dispatcher) Dispatch(task *database.Task) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build callback URL.
|
// Build callback URL.
|
||||||
callbackURL := fmt.Sprintf("http://%s/callback", d.cfg.ListenAddr)
|
callbackURL := buildCallbackURL(d.cfg.CallbackBaseURL)
|
||||||
|
|
||||||
payload := openClawRequest{
|
payload := openClawRequest{
|
||||||
Input: task.BodyPlain,
|
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:8080"
|
||||||
|
}
|
||||||
|
return trimmed + "/callback"
|
||||||
|
}
|
||||||
|
|
||||||
// callAPI makes a single HTTP POST to OpenClaw.
|
// callAPI makes a single HTTP POST to OpenClaw.
|
||||||
func (d *Dispatcher) callAPI(payload openClawRequest) error {
|
func (d *Dispatcher) callAPI(payload openClawRequest) error {
|
||||||
body, err := json.Marshal(payload)
|
body, err := json.Marshal(payload)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package ai_client_test
|
package ai_client_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -112,3 +113,45 @@ func TestDispatch_AllRetriesFailed(t *testing.T) {
|
|||||||
t.Errorf("expected attempt_openclaw = 3, got %d", updated.AttemptOpenClaw)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -36,6 +37,9 @@ type Config struct {
|
|||||||
|
|
||||||
// Server
|
// Server
|
||||||
ListenAddr string
|
ListenAddr string
|
||||||
|
|
||||||
|
// Public callback base URL for OpenClaw callbacks
|
||||||
|
CallbackBaseURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads .env (if present) and populates Config from environment variables.
|
// Load reads .env (if present) and populates Config from environment variables.
|
||||||
@@ -59,6 +63,7 @@ func Load() (*Config, error) {
|
|||||||
SystemEmail: os.Getenv("SYSTEM_EMAIL"),
|
SystemEmail: os.Getenv("SYSTEM_EMAIL"),
|
||||||
IMAPProxyURL: os.Getenv("IMAP_PROXY_URL"),
|
IMAPProxyURL: os.Getenv("IMAP_PROXY_URL"),
|
||||||
ListenAddr: os.Getenv("LISTEN_ADDR"),
|
ListenAddr: os.Getenv("LISTEN_ADDR"),
|
||||||
|
CallbackBaseURL: os.Getenv("CALLBACK_BASE_URL"),
|
||||||
}
|
}
|
||||||
|
|
||||||
if raw := os.Getenv("WHITELIST_EMAILS"); raw != "" {
|
if raw := os.Getenv("WHITELIST_EMAILS"); raw != "" {
|
||||||
@@ -72,6 +77,9 @@ func Load() (*Config, error) {
|
|||||||
if cfg.ListenAddr == "" {
|
if cfg.ListenAddr == "" {
|
||||||
cfg.ListenAddr = ":8080"
|
cfg.ListenAddr = ":8080"
|
||||||
}
|
}
|
||||||
|
if cfg.CallbackBaseURL == "" {
|
||||||
|
cfg.CallbackBaseURL = defaultCallbackBaseURL(cfg.ListenAddr)
|
||||||
|
}
|
||||||
|
|
||||||
if err := cfg.validate(); err != nil {
|
if err := cfg.validate(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -80,6 +88,17 @@ func Load() (*Config, error) {
|
|||||||
return cfg, nil
|
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.
|
// validate checks that all required configuration values are present.
|
||||||
func (c *Config) validate() error {
|
func (c *Config) validate() error {
|
||||||
required := map[string]string{
|
required := map[string]string{
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
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: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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user