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:
2026-04-27 18:16:57 +07:00
co-authored by Claude Opus 4.7
commit 8815c20c13
35 changed files with 2808 additions and 0 deletions
+35
View File
@@ -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
}
+35
View File
@@ -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
}
+65
View File
@@ -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
}
+105
View File
@@ -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))
}
}
+35
View File
@@ -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)
},
}
}