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,35 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// Open initializes the SQLite database and runs auto-migration.
|
||||
func Open(dbPath string) (*gorm.DB, error) {
|
||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent), // We use our own structured logger.
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("database open: %w", err)
|
||||
}
|
||||
|
||||
// Enable WAL mode for better concurrent read performance.
|
||||
if err := db.Exec("PRAGMA journal_mode=WAL").Error; err != nil {
|
||||
return nil, fmt.Errorf("database pragma WAL: %w", err)
|
||||
}
|
||||
|
||||
if err := db.AutoMigrate(&Task{}); err != nil {
|
||||
return nil, fmt.Errorf("database migrate: %w", err)
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// Healthy checks if the database is writable.
|
||||
func Healthy(db *gorm.DB) error {
|
||||
return db.Exec("SELECT 1").Error
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Task status constants matching the requirement's state machine.
|
||||
const (
|
||||
StatusIgnored = "IGNORED"
|
||||
StatusReceived = "RECEIVED"
|
||||
StatusAIProcessing = "AI_PROCESSING"
|
||||
StatusCallbackDone = "CALLBACK_DONE"
|
||||
StatusSMTPRetrying = "SMTP_RETRYING"
|
||||
StatusCompleted = "COMPLETED"
|
||||
StatusFailed = "FAILED"
|
||||
)
|
||||
|
||||
// Task represents a single email processing unit tracked through the pipeline.
|
||||
type Task struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement"`
|
||||
TaskUUID string `gorm:"uniqueIndex;not null"`
|
||||
ThreadID string `gorm:"index"`
|
||||
MessageID string `gorm:"index"`
|
||||
Sender string
|
||||
Subject string
|
||||
BodyPlain string
|
||||
AIResponse string
|
||||
Status string `gorm:"index;not null"`
|
||||
AttemptOpenClaw int `gorm:"default:0"`
|
||||
AttemptSMTP int `gorm:"default:0"`
|
||||
LastError *string
|
||||
NextRetryAt *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// FindByMessageID returns the task with the given message_id, or nil if not found.
|
||||
func FindByMessageID(db *gorm.DB, messageID string) (*Task, error) {
|
||||
var task Task
|
||||
err := db.Where("message_id = ?", messageID).First(&task).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &task, nil
|
||||
}
|
||||
|
||||
// FindLatestByThreadID returns the most recent COMPLETED task in the thread.
|
||||
func FindLatestByThreadID(db *gorm.DB, threadID string) (*Task, error) {
|
||||
var task Task
|
||||
err := db.Where("thread_id = ? AND status = ?", threadID, StatusCompleted).
|
||||
Order("created_at DESC").First(&task).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &task, nil
|
||||
}
|
||||
|
||||
// FindByTaskUUID returns the task with the given task_uuid.
|
||||
func FindByTaskUUID(db *gorm.DB, taskUUID string) (*Task, error) {
|
||||
var task Task
|
||||
err := db.Where("task_uuid = ?", taskUUID).First(&task).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &task, nil
|
||||
}
|
||||
|
||||
// ThreadHistory returns all COMPLETED tasks for a thread, ordered chronologically.
|
||||
func ThreadHistory(db *gorm.DB, threadID string) ([]Task, error) {
|
||||
var tasks []Task
|
||||
err := db.Where("thread_id = ? AND status = ?", threadID, StatusCompleted).
|
||||
Order("created_at ASC").Find(&tasks).Error
|
||||
return tasks, err
|
||||
}
|
||||
|
||||
// CreateTask inserts a new task record.
|
||||
func CreateTask(db *gorm.DB, task *Task) error {
|
||||
return db.Create(task).Error
|
||||
}
|
||||
|
||||
// UpdateTask saves changes to an existing task record.
|
||||
func UpdateTask(db *gorm.DB, task *Task) error {
|
||||
return db.Save(task).Error
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package database_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"thuanle.me/claw-email-bridge/internal/database"
|
||||
)
|
||||
|
||||
// Test matrix #5: Duplicate message_id → không tạo task mới.
|
||||
func TestDuplicateMessageID(t *testing.T) {
|
||||
tdb := database.NewTestDB(t)
|
||||
|
||||
task1 := &database.Task{
|
||||
TaskUUID: "uuid-1",
|
||||
ThreadID: "thread-1",
|
||||
MessageID: "msg-duplicate@test.com",
|
||||
Sender: "user@test.com",
|
||||
Subject: "First email",
|
||||
Status: database.StatusReceived,
|
||||
}
|
||||
if err := database.CreateTask(tdb.DB, task1); err != nil {
|
||||
t.Fatalf("create first task: %v", err)
|
||||
}
|
||||
|
||||
// Lookup should find the existing task.
|
||||
found, err := database.FindByMessageID(tdb.DB, "msg-duplicate@test.com")
|
||||
if err != nil {
|
||||
t.Fatalf("find by message_id: %v", err)
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatal("expected to find existing task")
|
||||
}
|
||||
if found.TaskUUID != "uuid-1" {
|
||||
t.Errorf("expected uuid-1, got %s", found.TaskUUID)
|
||||
}
|
||||
|
||||
// Attempting to create another task with same task_uuid should fail (unique constraint).
|
||||
task2 := &database.Task{
|
||||
TaskUUID: "uuid-1", // Same UUID — unique constraint violation.
|
||||
ThreadID: "thread-1",
|
||||
MessageID: "msg-duplicate@test.com",
|
||||
Sender: "user@test.com",
|
||||
Subject: "Duplicate",
|
||||
Status: database.StatusReceived,
|
||||
}
|
||||
err = database.CreateTask(tdb.DB, task2)
|
||||
if err == nil {
|
||||
t.Error("expected error creating duplicate task_uuid, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindByMessageID_NotFound(t *testing.T) {
|
||||
tdb := database.NewTestDB(t)
|
||||
|
||||
found, err := database.FindByMessageID(tdb.DB, "nonexistent@test.com")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if found != nil {
|
||||
t.Error("expected nil for nonexistent message_id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestThreadHistory(t *testing.T) {
|
||||
tdb := database.NewTestDB(t)
|
||||
|
||||
// Create two completed tasks in same thread.
|
||||
for i, uuid := range []string{"uuid-a", "uuid-b"} {
|
||||
task := &database.Task{
|
||||
TaskUUID: uuid,
|
||||
ThreadID: "thread-history",
|
||||
MessageID: uuid + "@test.com",
|
||||
Sender: "user@test.com",
|
||||
Subject: "Thread test",
|
||||
BodyPlain: "Message " + uuid,
|
||||
AIResponse: "Reply " + uuid,
|
||||
Status: database.StatusCompleted,
|
||||
}
|
||||
_ = i
|
||||
if err := database.CreateTask(tdb.DB, task); err != nil {
|
||||
t.Fatalf("create task %s: %v", uuid, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create one non-completed task — should not appear in history.
|
||||
task3 := &database.Task{
|
||||
TaskUUID: "uuid-c",
|
||||
ThreadID: "thread-history",
|
||||
MessageID: "uuid-c@test.com",
|
||||
Sender: "user@test.com",
|
||||
Subject: "Pending",
|
||||
Status: database.StatusAIProcessing,
|
||||
}
|
||||
if err := database.CreateTask(tdb.DB, task3); err != nil {
|
||||
t.Fatalf("create task: %v", err)
|
||||
}
|
||||
|
||||
history, err := database.ThreadHistory(tdb.DB, "thread-history")
|
||||
if err != nil {
|
||||
t.Fatalf("thread history: %v", err)
|
||||
}
|
||||
if len(history) != 2 {
|
||||
t.Errorf("expected 2 completed tasks in history, got %d", len(history))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TestDB holds a test database instance with automatic cleanup.
|
||||
type TestDB struct {
|
||||
DB *gorm.DB
|
||||
cleanup func()
|
||||
}
|
||||
|
||||
// NewTestDB creates a temporary SQLite database for testing.
|
||||
func NewTestDB(t *testing.T) *TestDB {
|
||||
t.Helper()
|
||||
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "test.db")
|
||||
|
||||
db, err := Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open test db: %v", err)
|
||||
}
|
||||
|
||||
return &TestDB{
|
||||
DB: db,
|
||||
cleanup: func() {
|
||||
os.RemoveAll(dir)
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user