Initial commit: email-openclaw bridge v1
Full pipeline: IMAP ingress -> OpenClaw dispatch -> callback -> SMTP reply. SQLite stateful storage with idempotency, threading, and retry logic. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"thuanle.me/claw-email-bridge/internal/database"
|
||||
"thuanle.me/claw-email-bridge/internal/logging"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// CallbackRequest is the payload received from OpenClaw.
|
||||
type CallbackRequest struct {
|
||||
Metadata struct {
|
||||
TaskUUID string `json:"task_uuid" binding:"required"`
|
||||
} `json:"metadata" binding:"required"`
|
||||
Result string `json:"result" binding:"required"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// OnCallbackDone is called after a task transitions to CALLBACK_DONE.
|
||||
// This will be wired to SMTP egress.
|
||||
type OnCallbackDone func(task *database.Task)
|
||||
|
||||
// NewRouter creates the Gin engine with health and callback routes.
|
||||
func NewRouter(db *gorm.DB, callbackToken string, onDone OnCallbackDone) *gin.Engine {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
|
||||
r.GET("/healthz", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
r.GET("/readyz", readyzHandler(db, callbackToken))
|
||||
r.POST("/callback", tokenAuthMiddleware(callbackToken), callbackHandler(db, onDone))
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// tokenAuthMiddleware validates X-Bridge-Token header.
|
||||
func tokenAuthMiddleware(expected string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := c.GetHeader("X-Bridge-Token")
|
||||
if token == "" || token != expected {
|
||||
slog.Warn("callback: unauthorized request",
|
||||
"remote_addr", c.ClientIP(),
|
||||
"has_token", token != "",
|
||||
)
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "unauthorized",
|
||||
})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// callbackHandler processes the callback from OpenClaw.
|
||||
func callbackHandler(db *gorm.DB, onDone OnCallbackDone) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req CallbackRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
taskUUID := req.Metadata.TaskUUID
|
||||
log := logging.TaskLogger(taskUUID, "", "")
|
||||
|
||||
// Find task.
|
||||
task, err := database.FindByTaskUUID(db, taskUUID)
|
||||
if err != nil {
|
||||
log.Error("callback: db lookup failed", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
||||
return
|
||||
}
|
||||
if task == nil {
|
||||
log.Warn("callback: task not found")
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "task not found"})
|
||||
return
|
||||
}
|
||||
|
||||
log = logging.TaskLogger(task.TaskUUID, task.ThreadID, task.MessageID)
|
||||
|
||||
// Idempotent: if already COMPLETED, return 200 without changes.
|
||||
if task.Status == database.StatusCompleted {
|
||||
log.Info("callback: task already completed, idempotent response")
|
||||
c.JSON(http.StatusOK, gin.H{"status": "already_completed"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update task with AI response.
|
||||
task.AIResponse = req.Result
|
||||
task.Status = database.StatusCallbackDone
|
||||
if err := database.UpdateTask(db, task); err != nil {
|
||||
log.Error("callback: failed to update task", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Info("callback: task updated",
|
||||
"status", database.StatusCallbackDone,
|
||||
)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
|
||||
// Trigger SMTP egress (async, after response is sent).
|
||||
if onDone != nil {
|
||||
go onDone(task)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// readyzHandler checks DB writable + required config present.
|
||||
func readyzHandler(db *gorm.DB, callbackToken string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if err := database.Healthy(db); err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"status": "error",
|
||||
"detail": "database not writable",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if callbackToken == "" {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"status": "error",
|
||||
"detail": "missing BRIDGE_CALLBACK_TOKEN",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"thuanle.me/claw-email-bridge/internal/api"
|
||||
"thuanle.me/claw-email-bridge/internal/database"
|
||||
)
|
||||
|
||||
const testToken = "test-secret-token"
|
||||
|
||||
func setupTestRouter(t *testing.T) (*database.TestDB, *httptest.Server) {
|
||||
t.Helper()
|
||||
tdb := database.NewTestDB(t)
|
||||
router := api.NewRouter(tdb.DB, testToken, nil)
|
||||
srv := httptest.NewServer(router)
|
||||
t.Cleanup(func() { srv.Close() })
|
||||
return tdb, srv
|
||||
}
|
||||
|
||||
// Test matrix #4: Callback sai token → 401, state không đổi.
|
||||
func TestCallback_WrongToken_Returns401(t *testing.T) {
|
||||
tdb, srv := setupTestRouter(t)
|
||||
|
||||
// Create a task in AI_PROCESSING state.
|
||||
task := &database.Task{
|
||||
TaskUUID: "uuid-wrong-token",
|
||||
ThreadID: "thread-1",
|
||||
MessageID: "msg-1",
|
||||
Sender: "user@test.com",
|
||||
Subject: "Test",
|
||||
Status: database.StatusAIProcessing,
|
||||
}
|
||||
if err := database.CreateTask(tdb.DB, task); err != nil {
|
||||
t.Fatalf("create task: %v", err)
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"metadata": map[string]string{"task_uuid": "uuid-wrong-token"},
|
||||
"result": "AI response",
|
||||
"status": "ok",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
// No token.
|
||||
resp, err := http.Post(srv.URL+"/callback", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Wrong token.
|
||||
req, _ := http.NewRequest("POST", srv.URL+"/callback", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Bridge-Token", "wrong-token")
|
||||
resp2, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
if resp2.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", resp2.StatusCode)
|
||||
}
|
||||
|
||||
// Verify state unchanged.
|
||||
updated, err := database.FindByTaskUUID(tdb.DB, "uuid-wrong-token")
|
||||
if err != nil {
|
||||
t.Fatalf("find task: %v", err)
|
||||
}
|
||||
if updated.Status != database.StatusAIProcessing {
|
||||
t.Errorf("expected status AI_PROCESSING, got %s", updated.Status)
|
||||
}
|
||||
if updated.AIResponse != "" {
|
||||
t.Errorf("expected empty ai_response, got %q", updated.AIResponse)
|
||||
}
|
||||
}
|
||||
|
||||
// Test matrix #1 (partial): callback hợp lệ → CALLBACK_DONE.
|
||||
func TestCallback_ValidToken_UpdatesTask(t *testing.T) {
|
||||
tdb, srv := setupTestRouter(t)
|
||||
|
||||
task := &database.Task{
|
||||
TaskUUID: "uuid-valid",
|
||||
ThreadID: "thread-1",
|
||||
MessageID: "msg-1",
|
||||
Sender: "user@test.com",
|
||||
Subject: "Test",
|
||||
Status: database.StatusAIProcessing,
|
||||
}
|
||||
if err := database.CreateTask(tdb.DB, task); err != nil {
|
||||
t.Fatalf("create task: %v", err)
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"metadata": map[string]string{"task_uuid": "uuid-valid"},
|
||||
"result": "Hello from AI",
|
||||
"status": "ok",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
req, _ := http.NewRequest("POST", srv.URL+"/callback", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Bridge-Token", testToken)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
updated, _ := database.FindByTaskUUID(tdb.DB, "uuid-valid")
|
||||
if updated.Status != database.StatusCallbackDone {
|
||||
t.Errorf("expected CALLBACK_DONE, got %s", updated.Status)
|
||||
}
|
||||
if updated.AIResponse != "Hello from AI" {
|
||||
t.Errorf("expected 'Hello from AI', got %q", updated.AIResponse)
|
||||
}
|
||||
}
|
||||
|
||||
// Idempotent callback: task already COMPLETED → 200, no changes.
|
||||
func TestCallback_AlreadyCompleted_Idempotent(t *testing.T) {
|
||||
tdb, srv := setupTestRouter(t)
|
||||
|
||||
task := &database.Task{
|
||||
TaskUUID: "uuid-completed",
|
||||
ThreadID: "thread-1",
|
||||
MessageID: "msg-1",
|
||||
Sender: "user@test.com",
|
||||
Subject: "Test",
|
||||
Status: database.StatusCompleted,
|
||||
AIResponse: "Original response",
|
||||
}
|
||||
if err := database.CreateTask(tdb.DB, task); err != nil {
|
||||
t.Fatalf("create task: %v", err)
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"metadata": map[string]string{"task_uuid": "uuid-completed"},
|
||||
"result": "New response that should be ignored",
|
||||
"status": "ok",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
req, _ := http.NewRequest("POST", srv.URL+"/callback", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Bridge-Token", testToken)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
updated, _ := database.FindByTaskUUID(tdb.DB, "uuid-completed")
|
||||
if updated.AIResponse != "Original response" {
|
||||
t.Errorf("expected original response preserved, got %q", updated.AIResponse)
|
||||
}
|
||||
}
|
||||
|
||||
// Health endpoints.
|
||||
func TestHealthz(t *testing.T) {
|
||||
_, srv := setupTestRouter(t)
|
||||
|
||||
resp, err := http.Get(srv.URL + "/healthz")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadyz(t *testing.T) {
|
||||
_, srv := setupTestRouter(t)
|
||||
|
||||
resp, err := http.Get(srv.URL + "/readyz")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user