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
+17
View File
@@ -0,0 +1,17 @@
# Checkpoint 001 — Feedback kết quả kiểm tra
## Kết luận
**Đạt** theo tiêu chí checkpoint.
## Bằng chứng
- Verify command: `go build ./...` chạy thành công.
- Cấu trúc scaffold khớp mô tả checkpoint:
-`cmd/bridge/main.go`, `internal/config`, `internal/database`, `internal/logging`, `internal/api`.
-`.env.example`, `Dockerfile`, `docker-compose.yml`, `go.mod`, `go.sum`.
- Các phần cốt lõi scaffold hiện diện:
- SQLite + WAL + auto-migrate.
- `/healthz`, `/readyz`.
- Validate config bắt buộc.
## Lưu ý (không chặn checkpoint)
- README mô tả tính năng pipeline/retry/threading khá đầy đủ, nhưng tại checkpoint 001 phần mail/ai vẫn chủ yếu ở mức scaffold/stub. Đây không mâu thuẫn với checkpoint 001, chỉ cần hiểu đúng phạm vi.
+91
View File
@@ -0,0 +1,91 @@
# Checkpoint 001 — Project Scaffold
**Ngày:** 2026-04-24
**Trạng thái:** ✅ Hoàn thành
**Verify:** `go build ./...` thành công, không lỗi
---
## Việc đã làm
### 1. Khởi tạo Go module
```
go mod init github.com/thuanle/claw-email
```
### 2. Tạo cấu trúc thư mục
```
claw-email/
├── cmd/bridge/main.go # Entrypoint
├── internal/
│ ├── config/config.go # Đọc .env, validate required vars
│ ├── database/
│ │ ├── database.go # Open SQLite + auto-migrate + health check
│ │ └── models.go # Task model (đầy đủ schema từ requirements)
│ ├── logging/logger.go # Structured JSON logging (slog)
│ ├── api/router.go # Gin router: /healthz, /readyz
│ ├── mail/mail.go # [stub] IMAP ingress + SMTP egress
│ └── ai_client/client.go # [stub] OpenClaw HTTP dispatch
├── .env.example
├── .gitignore
├── Dockerfile # Multi-stage, CGO_ENABLED=0
├── docker-compose.yml # Named volume cho SQLite
├── go.mod / go.sum
└── requirements.md
```
### 3. Kết nối SQLite + auto-migrate bảng `tasks`
- Driver: `github.com/glebarez/sqlite` (pure Go, wraps `modernc.org/sqlite`)
- WAL mode enabled
- Auto-migrate bảng `tasks` khi khởi động
- Health check: `SELECT 1`
### 4. Module config (.env)
- Dùng `github.com/joho/godotenv` load `.env`
- Validate tất cả biến bắt buộc, trả lỗi rõ ràng nếu thiếu
- Parse `WHITELIST_EMAILS` (comma-separated)
- Default `LISTEN_ADDR=:8080`
### 5. Structured Logging
- Dùng `log/slog` (Go stdlib 1.21+), JSON handler ra stdout
- Helper `TaskLogger(taskUUID, threadID, messageID)` — mọi log liên quan task đều có context
### 6. API endpoints (Gin)
- `GET /healthz``200 {"status":"ok"}` (liveness)
- `GET /readyz` → kiểm tra DB writable + config token (readiness)
### 7. Docker
- **Dockerfile**: Multi-stage, `golang:1.23-alpine``alpine:3.20`, `CGO_ENABLED=0`
- **docker-compose.yml**: Named volume `bridge-data:/app/data`, `env_file: .env`
---
## Quyết định thiết kế
| Quyết định | Lý do |
|---|---|
| `glebarez/sqlite` thay vì `gorm.io/driver/sqlite` | Pure Go, không cần CGO → Docker build đơn giản |
| `log/slog` thay vì zap/logrus | Stdlib, zero dependency, đủ dùng |
| `gin-gonic/gin` | Đúng requirements, nhẹ, phổ biến |
| WAL mode SQLite | Tăng throughput đọc concurrent |
| `internal/` layout | Chuẩn Go, ngăn import từ bên ngoài |
## Dependencies (go.mod)
```
github.com/gin-gonic/gin v1.12.0
github.com/glebarez/sqlite v1.11.0 (→ modernc.org/sqlite)
github.com/joho/godotenv v1.5.1
gorm.io/gorm v1.31.1
```
---
## Tiếp theo (chưa implement)
1. IMAP Ingress — IDLE, anti-loop, whitelist, idempotency, threading
2. OpenClaw Dispatch — HTTP POST, retry 3 lần (1s/5s/15s)
3. Callback handler — webhook xác thực token, cập nhật task
4. SMTP Egress — gửi reply, In-Reply-To/References, retry
5. Integration tests — 6 scenarios từ requirements
+15
View File
@@ -0,0 +1,15 @@
# Checkpoint 002 — Feedback kết quả kiểm tra
## Kết luận
**Đạt** theo tiêu chí checkpoint.
## Bằng chứng
- Verify command: `go build ./...` chạy thành công.
- Khớp claims chính của checkpoint 002:
-`internal/database/repository.go` với các helper: `FindByMessageID`, `FindLatestByThreadID`, `FindByTaskUUID`, `ThreadHistory`, `CreateTask`, `UpdateTask`.
-`internal/mail/imap.go` với: IMAP IDLE loop, reconnect sau 10s, fetch UNSEEN khi startup, anti-loop, whitelist, idempotency, threading theo `In-Reply-To`, save task `RECEIVED`, mark `\\Seen`.
- `cmd/bridge/main.go` chạy IMAP watcher + HTTP server concurrently, có graceful shutdown.
- Stub cũ `internal/mail/mail.go` đã được xóa.
## Lưu ý (không chặn checkpoint)
- Threading hiện parse `In-Reply-To`; chưa thấy fallback rõ ràng theo `References` như requirement đầy đủ. Nên bổ sung ở checkpoint tiếp theo.
+82
View File
@@ -0,0 +1,82 @@
# Checkpoint 002 — IMAP Ingress
**Ngày:** 2026-04-24
**Trạng thái:** ✅ Hoàn thành
**Verify:** `go build ./...` thành công
---
## Việc đã làm
### 1. Task Repository (`internal/database/repository.go`)
Thêm các helper function cho CRUD operations:
- `FindByMessageID` — lookup cho idempotency check
- `FindLatestByThreadID` — tìm task COMPLETED gần nhất trong thread
- `FindByTaskUUID` — lookup cho callback handler
- `ThreadHistory` — lấy toàn bộ history thread (cho OpenClaw context)
- `CreateTask`, `UpdateTask` — CRUD cơ bản
### 2. IMAP Watcher (`internal/mail/imap.go`)
Module chính cho Ingress, bao gồm:
**Kết nối & IDLE:**
- `DialTLS` → Login → Select INBOX
- IDLE loop với timer 28 phút (RFC recommends < 29 min)
- Auto-reconnect sau 10s khi mất kết nối
- Graceful shutdown qua `context.Context`
- `UnilateralDataHandler` nhận notification khi có email mới
**Xử lý email (đúng 5 bước theo requirements 4.1):**
1. **Anti-loop**: sender == `SYSTEM_EMAIL``IGNORED`
2. **Whitelist**: sender không trong danh sách → `IGNORED` (empty whitelist = cho phép tất cả)
3. **Idempotency**: `message_id` đã tồn tại → skip
4. **Threading**: parse `In-Reply-To` header → tìm parent task → kế thừa `thread_id`
5. **Save**: tạo task `status = RECEIVED`
**Extras:**
- Fetch UNSEEN messages on startup (xử lý email đến trong lúc offline)
- Mark `\Seen` sau khi xử lý xong
- Lưu task IGNORED vào DB cho audit
- Parse body parts qua `go-message` để lấy plain text
- Hook `OnReceived` cho dispatch (chưa wire)
### 3. Entrypoint (`cmd/bridge/main.go`)
- Chạy IMAP watcher + HTTP server concurrently
- Graceful shutdown qua SIGINT/SIGTERM
- `sync.WaitGroup` đợi cả hai goroutine
---
## Dependencies mới
```
github.com/emersion/go-imap/v2 v2.0.0-beta.8
github.com/emersion/go-message v0.18.2
github.com/google/uuid (for task_uuid generation)
```
## Quyết định thiết kế
| Quyết định | Lý do |
|---|---|
| go-imap v2 thay vì v1 | IDLE tích hợp sẵn, API hiện đại hơn, dự án mới nên dùng v2 |
| IDLE timer 28 min | RFC recommends < 29 min, close + reopen IDLE để tránh server timeout |
| Fetch UNSEEN on startup | Xử lý email đến lúc bridge offline |
| Empty whitelist = allow all | UX tốt hơn: không cấu hình whitelist thì mặc định chấp nhận mọi sender |
| Lưu IGNORED vào DB | Auditability: biết email nào bị bỏ qua và lý do |
| `UnilateralDataHandler` | Nhận notification realtime khi mailbox thay đổi (không cần polling) |
---
## Files thay đổi
- **Mới:** `internal/database/repository.go`
- **Mới:** `internal/mail/imap.go`
- **Cập nhật:** `cmd/bridge/main.go` (IMAP watcher + graceful shutdown)
- **Xóa:** `internal/mail/mail.go` (stub cũ)
## Tiếp theo
1. **OpenClaw Dispatch** — wire `OnReceived` → HTTP POST to OpenClaw
2. **Callback handler**`POST /callback` + token auth
3. **SMTP Egress** — gửi reply với In-Reply-To/References
+14
View File
@@ -0,0 +1,14 @@
# Checkpoint 003 — Feedback kết quả kiểm tra
## Kết luận
**Đạt** theo tiêu chí checkpoint.
## Bằng chứng
- Verify command: `go build ./...` chạy thành công.
- `internal/ai_client/client.go` đã có dispatch payload đúng spec: `input`, `session_id`, `history`, `callback_url`, `metadata.task_uuid`.
- Retry OpenClaw đúng 3 lần với backoff `1s -> 5s -> 15s`, cập nhật `attempt_openclaw`, hết retry thì `status=FAILED` + `last_error`.
- HTTP client đi direct (`Transport.Proxy=nil`), timeout 30s, bearer token optional.
- `cmd/bridge/main.go` đã wire `watcher.OnReceived -> go dispatcher.Dispatch(task)`.
## Lưu ý (không chặn checkpoint)
- `callback_url` hiện dùng `http://<LISTEN_ADDR>/callback`; khi deploy qua domain/public endpoint có thể cần biến cấu hình base URL riêng.
+45
View File
@@ -0,0 +1,45 @@
# Checkpoint 003 — OpenClaw Dispatch
**Ngày:** 2026-04-24
**Trạng thái:** ✅ Hoàn thành
**Verify:** `go build ./...` thành công
---
## Việc đã làm
### OpenClaw Dispatcher (`internal/ai_client/client.go`)
**Chức năng chính:**
- Gửi HTTP POST đến OpenClaw API với payload theo spec:
- `input` = body email
- `session_id` = thread_id
- `history` = các cặp user/assistant từ tasks COMPLETED cùng thread
- `callback_url` = endpoint nhận kết quả
- `metadata.task_uuid` = ID để callback tìm lại task
**Retry logic (đúng requirements 4.2):**
- Tối đa 3 lần, backoff: 1s → 5s → 15s
- Mỗi lần retry tăng `attempt_openclaw`
- Hết retry: `status = FAILED`, ghi `last_error`
**HTTP Client:**
- `Transport.Proxy = nil` — direct connection, không kế thừa proxy hệ thống
- Timeout 30s
- Bearer token auth (nếu `OPENCLAW_API_KEY` có giá trị)
### Wiring (`cmd/bridge/main.go`)
- `watcher.OnReceived``go dispatcher.Dispatch(task)` (async)
- Pipeline hoàn chỉnh: IMAP → RECEIVED → AI_PROCESSING → (chờ callback)
---
## Files thay đổi
- **Rewrite:** `internal/ai_client/client.go` (từ stub → full implementation)
- **Cập nhật:** `cmd/bridge/main.go` (wire dispatcher)
## Tiếp theo
1. **Callback handler**`POST /callback` + token auth + cập nhật `ai_response`
2. **SMTP Egress** — gửi reply với In-Reply-To/References + retry
+48
View File
@@ -0,0 +1,48 @@
# Checkpoint 004 — Callback Handler
**Ngày:** 2026-04-24
**Trạng thái:** ✅ Hoàn thành
**Verify:** `go build ./...` thành công
---
## Việc đã làm
### Callback Handler (`internal/api/router.go`)
**Endpoint:** `POST /callback`
**Xác thực (requirements 5.2):**
- Middleware `tokenAuthMiddleware` kiểm tra header `X-Bridge-Token`
- Thiếu hoặc sai token → `401 Unauthorized`
- Log warning kèm `remote_addr`
**Xử lý callback (requirements 4.3 + 5.1):**
- Parse payload: `metadata.task_uuid` (required), `result` (required), `status`
- Tìm task theo `task_uuid` → 404 nếu không tìm thấy
- **Idempotent:** task đã `COMPLETED` → trả `200 {"status":"already_completed"}`, không thay đổi gì
- Cập nhật `ai_response = result`, `status = CALLBACK_DONE`
- Gọi async `onDone(task)` sau khi trả response (hook cho SMTP egress)
**API Signature thay đổi:**
- `NewRouter(db, callbackToken, onDone)` — thêm param `OnCallbackDone` callback
- `main.go` truyền `nil` tạm (sẽ wire SMTP egress ở bước sau)
---
## Files thay đổi
- **Rewrite:** `internal/api/router.go` (thêm callback handler + token auth middleware)
- **Cập nhật:** `cmd/bridge/main.go` (cập nhật signature `NewRouter`)
## Pipeline hiện tại
```
IMAP IDLE → RECEIVED → AI_PROCESSING → (OpenClaw callback) → CALLBACK_DONE → ???
[chờ SMTP egress]
```
## Tiếp theo
1. **SMTP Egress** — gửi reply email với In-Reply-To/References + retry 3 lần
+18
View File
@@ -0,0 +1,18 @@
# Checkpoint 004 — Feedback kết quả kiểm tra
## Kết luận
**Đạt** theo tiêu chí checkpoint.
## Bằng chứng
- Verify command: `go build ./...` chạy thành công.
- `internal/api/router.go``POST /callback`.
- Có middleware kiểm tra `X-Bridge-Token`; thiếu/sai token trả `401 Unauthorized` và log warning kèm `remote_addr`.
- Callback parse payload với `metadata.task_uuid` + `result` là required.
- Lookup task theo `task_uuid`; không thấy trả `404`.
- Idempotent path: task đã `COMPLETED` trả `200 {"status":"already_completed"}`.
- Callback hợp lệ cập nhật `ai_response`, `status=CALLBACK_DONE`.
- `NewRouter(db, callbackToken, onDone)` đã đổi signature; `cmd/bridge/main.go` truyền `nil` tạm.
## Lưu ý (không chặn checkpoint)
- Hiện `onDone` chưa wire sang SMTP egress (đúng như checkpoint ghi “bước sau”).
- Trường `status` từ payload hiện chưa được dùng trong logic callback.
+14
View File
@@ -0,0 +1,14 @@
# Checkpoint 005 — Feedback kết quả kiểm tra
## Kết luận
**Đạt** theo tiêu chí checkpoint.
## Bằng chứng
- Verify command: `go build ./...` chạy thành công.
-`internal/mail/smtp.go` với gửi reply SMTP và retry 3 lần theo backoff `1s -> 5s -> 15s`.
- Mỗi lần retry tăng `attempt_smtp`, set `status=SMTP_RETRYING`; hết retry set `status=FAILED` + `last_error`; thành công set `status=COMPLETED`.
- Email reply có threading headers `In-Reply-To``References`, body lấy từ `task.AIResponse`.
- `cmd/bridge/main.go` đã wire SMTP egress qua `api.NewRouter(..., smtpSender.SendReplyFunc())`.
## Lưu ý (không chặn checkpoint)
- Header threading đang dùng `task.MessageID` trực tiếp; một số hệ thống mail đôi khi yêu cầu định dạng `<message-id>` nhất quán để thread ổn định hơn.
+61
View File
@@ -0,0 +1,61 @@
# Checkpoint 005 — SMTP Egress
**Ngày:** 2026-04-24
**Trạng thái:** ✅ Hoàn thành
**Verify:** `go build ./...` thành công
---
## Việc đã làm
### SMTP Sender (`internal/mail/smtp.go`)
**Gửi reply email (requirements 4.3):**
- Subject: thêm prefix `Re:` nếu chưa có
- Body: `task.AIResponse` từ OpenClaw callback
- Threading headers:
- `In-Reply-To: <original message_id>`
- `References: <original message_id>`
- MIME: `text/plain; charset=UTF-8`
**Retry logic (đúng requirements):**
- Tối đa 3 lần, backoff: 1s → 5s → 15s
- Mỗi lần retry: tăng `attempt_smtp`, status = `SMTP_RETRYING`
- Hết retry: `status = FAILED`, ghi `last_error`
- Thành công: `status = COMPLETED`
**SMTP delivery:**
- `net/smtp.SendMail` với `PlainAuth`
- Địa chỉ: `SMTP_HOST:SMTP_PORT`
- From: `SYSTEM_EMAIL`
### Wiring (`cmd/bridge/main.go`)
- `smtpSender.SendReplyFunc()``api.NewRouter(..., onDone)`
- Callback handler gọi `onDone(task)` async sau khi trả response
---
## Pipeline hoàn chỉnh 🎉
```
IMAP IDLE → RECEIVED → AI_PROCESSING → (OpenClaw callback) → CALLBACK_DONE → SMTP reply → COMPLETED
↓ ↓ ↓
IGNORED FAILED FAILED
```
Tất cả transitions đã được implement:
- IMAP → RECEIVED ✅
- RECEIVED → AI_PROCESSING ✅
- AI_PROCESSING → CALLBACK_DONE ✅
- CALLBACK_DONE → SMTP_RETRYING → COMPLETED/FAILED ✅
- Anti-loop/Whitelist → IGNORED ✅
- Retry exhausted → FAILED ✅
## Files thay đổi
- **Mới:** `internal/mail/smtp.go`
- **Cập nhật:** `cmd/bridge/main.go` (wire SMTP sender)
## Tiếp theo
1. **Integration tests** — 6 scenarios từ requirements section 10
+44
View File
@@ -0,0 +1,44 @@
# Checkpoint 006 — Tests
**Ngày:** 2026-04-24
**Trạng thái:** ✅ Hoàn thành
**Verify:** `go test ./... -v` — 10/10 PASS
---
## Test matrix coverage
| # | Scenario | Test | Status |
|---|----------|------|--------|
| 1 | Callback hợp lệ → CALLBACK_DONE | `TestCallback_ValidToken_UpdatesTask` | ✅ PASS |
| 2 | OpenClaw fail 2 lần, lần 3 OK → attempt=3 | `TestDispatch_RetryThenSuccess` | ✅ PASS |
| 2' | OpenClaw fail hết → FAILED + last_error | `TestDispatch_AllRetriesFailed` | ✅ PASS |
| 3 | SMTP fail hết retry → FAILED | *(cần SMTP mock — chưa test)* | ⏳ |
| 4 | Callback sai token → 401, state không đổi | `TestCallback_WrongToken_Returns401` | ✅ PASS |
| 5 | Duplicate message_id → không tạo task mới | `TestDuplicateMessageID` | ✅ PASS |
| 6 | Anti-loop / whitelist → IGNORED | *(logic test trong IMAP — chưa test)* | ⏳ |
**Bonus tests:**
- `TestCallback_AlreadyCompleted_Idempotent` — idempotent callback ✅
- `TestFindByMessageID_NotFound` — empty result handling ✅
- `TestThreadHistory` — chỉ trả COMPLETED tasks ✅
- `TestHealthz`, `TestReadyz` — health endpoints ✅
## Kỹ thuật test
- **Test database:** `database.NewTestDB(t)` tạo SQLite tạm trong `t.TempDir()`
- **HTTP mock:** `httptest.NewServer` cho OpenClaw API
- **HTTP test:** `httptest.NewServer(router)` cho callback handler
- **Retry timing:** Tests thật sự chờ backoff (1s+5s = ~6s mỗi retry test)
## Files mới
- `internal/database/testhelper.go` — TestDB helper
- `internal/database/repository_test.go` — repository tests
- `internal/api/router_test.go` — callback + health tests
- `internal/ai_client/client_test.go` — dispatch retry tests
## Chưa test (cần mock phức tạp hơn)
- SMTP retry logic (#3) — cần mock `net/smtp.SendMail`
- IMAP anti-loop/whitelist (#6) — cần refactor `processMessage` để testable