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>
138 lines
3.7 KiB
Go
138 lines
3.7 KiB
Go
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"})
|
|
}
|
|
}
|