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
+24
View File
@@ -0,0 +1,24 @@
# === IMAP ===
IMAP_HOST=imap.example.com
IMAP_PORT=993
IMAP_USER=bridge@example.com
IMAP_PASS=changeme
# === SMTP ===
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=bridge@example.com
SMTP_PASS=changeme
# === OpenClaw ===
OPENCLAW_URL=https://api.openclaw.example.com
OPENCLAW_API_KEY=
# === Bridge ===
BRIDGE_CALLBACK_TOKEN=a-strong-random-token
SYSTEM_EMAIL=bridge@example.com
WHITELIST_EMAILS=user1@example.com,user2@example.com
# === Optional ===
IMAP_PROXY_URL=
LISTEN_ADDR=:8080
+16
View File
@@ -0,0 +1,16 @@
# Environment
.env
# Database
data/*.db
data/*.db-wal
data/*.db-shm
# Go
/bin/
/tmp/
# IDE
.idea/
.vscode/
*.swp
+70
View File
@@ -0,0 +1,70 @@
# CLAUDE.md
Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
## 1. Think Before Coding
**Don't assume. Don't hide confusion. Surface tradeoffs.**
Before implementing:
- State your assumptions explicitly. If uncertain, ask.
- If multiple interpretations exist, present them - don't pick silently.
- If a simpler approach exists, say so. Push back when warranted.
- If something is unclear, stop. Name what's confusing. Ask.
## 2. Simplicity First
**Minimum code that solves the problem. Nothing speculative.**
- No features beyond what was asked.
- No abstractions for single-use code.
- No "flexibility" or "configurability" that wasn't requested.
- No error handling for impossible scenarios.
- If you write 200 lines and it could be 50, rewrite it.
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
## 3. Surgical Changes
**Touch only what you must. Clean up only your own mess.**
When editing existing code:
- Don't "improve" adjacent code, comments, or formatting.
- Don't refactor things that aren't broken.
- Match existing style, even if you'd do it differently.
- If you notice unrelated dead code, mention it - don't delete it.
When your changes create orphans:
- Remove imports/variables/functions that YOUR changes made unused.
- Don't remove pre-existing dead code unless asked.
The test: Every changed line should trace directly to the user's request.
## 4. Goal-Driven Execution
**Define success criteria. Loop until verified.**
Transform tasks into verifiable goals:
- "Add validation" → "Write tests for invalid inputs, then make them pass"
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
- "Refactor X" → "Ensure tests pass before and after"
For multi-step tasks, state a brief plan:
```
1. [Step] → verify: [check]
2. [Step] → verify: [check]
3. [Step] → verify: [check]
```
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
---
**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
+29
View File
@@ -0,0 +1,29 @@
# ---- Build stage ----
FROM golang:1.23-alpine AS builder
WORKDIR /src
# Cache module downloads.
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# CGO disabled — using modernc.org/sqlite (pure Go).
RUN CGO_ENABLED=0 go build -o /app/bridge ./cmd/bridge
# ---- Runtime stage ----
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=builder /app/bridge .
# Ensure data directory exists for SQLite volume mount.
RUN mkdir -p /app/data
EXPOSE 8080
ENTRYPOINT ["./bridge"]
+105
View File
@@ -0,0 +1,105 @@
# Email-OpenClaw Bridge
Tự động xử lý email bằng OpenClaw AI theo luồng:
```
Ingress (IMAP) → Dispatch (OpenClaw) → Callback → Egress (SMTP reply)
```
## Tính năng
- **IMAP IDLE** — theo dõi mailbox realtime, nhận email mới tức thì
- **Threading** — giữ nguyên email thread qua `In-Reply-To` / `References`
- **Idempotency** — chống duplicate theo `message_id`
- **Anti-loop & Whitelist** — bỏ qua email từ chính hệ thống, chỉ xử lý sender được phép
- **Retry** — tối đa 3 lần với backoff 1s → 5s → 15s cho cả OpenClaw và SMTP
- **Stateful** — SQLite lưu trạng thái pipeline, không mất dữ liệu khi restart
## Kiến trúc
```
cmd/bridge/main.go Entrypoint
internal/
├── config/ Đọc cấu hình từ .env
├── database/ SQLite + GORM, auto-migrate
├── logging/ Structured JSON logging (slog)
├── api/ HTTP server (Gin): health checks, webhook callback
├── mail/ IMAP ingress + SMTP egress
└── ai_client/ OpenClaw HTTP dispatch
```
## Yêu cầu
- Go 1.23+
- Docker & Docker Compose (cho deploy)
## Quickstart
### 1. Cấu hình
```bash
cp .env.example .env
# Sửa .env với thông tin IMAP/SMTP/OpenClaw thực tế
```
### 2. Chạy local (development)
```bash
go run ./cmd/bridge
```
### 3. Chạy bằng Docker
```bash
docker compose up -d
docker logs -f claw-email-bridge
```
## Cấu hình (.env)
| Biến | Bắt buộc | Mô tả |
|---|:---:|---|
| `IMAP_HOST` | ✅ | IMAP server hostname |
| `IMAP_PORT` | ✅ | IMAP port (thường `993`) |
| `IMAP_USER` | ✅ | IMAP username |
| `IMAP_PASS` | ✅ | IMAP password |
| `SMTP_HOST` | ✅ | SMTP server hostname |
| `SMTP_PORT` | ✅ | SMTP port (thường `587`) |
| `SMTP_USER` | ✅ | SMTP username |
| `SMTP_PASS` | ✅ | SMTP password |
| `OPENCLAW_URL` | ✅ | OpenClaw API endpoint |
| `OPENCLAW_API_KEY` | | OpenClaw API key (nếu cần) |
| `BRIDGE_CALLBACK_TOKEN` | ✅ | Token xác thực webhook callback |
| `SYSTEM_EMAIL` | ✅ | Email của hệ thống (dùng cho anti-loop) |
| `WHITELIST_EMAILS` | | Danh sách email được phép, phân cách bằng dấu phẩy |
| `IMAP_PROXY_URL` | | SOCKS5 proxy cho IMAP (vd: `socks5://user:pass@host:port`) |
| `LISTEN_ADDR` | | Địa chỉ HTTP server (mặc định `:8080`) |
## Health Checks
| Endpoint | Mục đích |
|---|---|
| `GET /healthz` | Liveness — process còn sống |
| `GET /readyz` | Readiness — DB writable + config hợp lệ |
## Pipeline trạng thái
```
RECEIVED → AI_PROCESSING → CALLBACK_DONE → COMPLETED
↓ ↓ ↓
IGNORED FAILED SMTP_RETRYING → FAILED
```
## Công nghệ
| Thành phần | Thư viện |
|---|---|
| HTTP Server | [gin-gonic/gin](https://github.com/gin-gonic/gin) |
| Database | [GORM](https://gorm.io) + [modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite) (pure Go, no CGO) |
| IMAP | [emersion/go-imap](https://github.com/emersion/go-imap) |
| Logging | [log/slog](https://pkg.go.dev/log/slog) (stdlib) |
| Config | [joho/godotenv](https://github.com/joho/godotenv) |
## License
Private — internal use only.
+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
+77
View File
@@ -0,0 +1,77 @@
package main
import (
"context"
"log/slog"
"os"
"os/signal"
"sync"
"syscall"
"thuanle.me/claw-email-bridge/internal/ai_client"
"thuanle.me/claw-email-bridge/internal/api"
"thuanle.me/claw-email-bridge/internal/config"
"thuanle.me/claw-email-bridge/internal/database"
"thuanle.me/claw-email-bridge/internal/logging"
"thuanle.me/claw-email-bridge/internal/mail"
)
func main() {
logging.Setup()
cfg, err := config.Load()
if err != nil {
slog.Error("failed to load config", "error", err)
os.Exit(1)
}
slog.Info("config loaded successfully")
db, err := database.Open("data/database.db")
if err != nil {
slog.Error("failed to open database", "error", err)
os.Exit(1)
}
slog.Info("database ready", "path", "data/database.db")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Graceful shutdown on SIGINT/SIGTERM.
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
sig := <-sigCh
slog.Info("received signal, shutting down", "signal", sig)
cancel()
}()
var wg sync.WaitGroup
// Start IMAP watcher with OpenClaw dispatch.
dispatcher := ai_client.NewDispatcher(cfg, db)
watcher := mail.NewIMAPWatcher(cfg, db)
watcher.OnReceived = func(task *database.Task) {
go dispatcher.Dispatch(task)
}
wg.Add(1)
go func() {
defer wg.Done()
watcher.Run(ctx)
}()
// Start HTTP server with SMTP egress on callback.
smtpSender := mail.NewSMTPSender(cfg, db)
router := api.NewRouter(db, cfg.BridgeCallbackToken, smtpSender.SendReplyFunc())
wg.Add(1)
go func() {
defer wg.Done()
slog.Info("starting HTTP server", "addr", cfg.ListenAddr)
if err := router.Run(cfg.ListenAddr); err != nil {
slog.Error("server exited with error", "error", err)
cancel()
}
}()
wg.Wait()
slog.Info("bridge stopped")
}
+15
View File
@@ -0,0 +1,15 @@
services:
bridge:
build: .
container_name: claw-email-bridge
restart: unless-stopped
env_file:
- .env
ports:
- "8080:8080"
volumes:
- bridge-data:/app/data
volumes:
bridge-data:
driver: local
@@ -0,0 +1,91 @@
# Email-OpenClaw Bridge v1 Design (KISS)
## 1) Scope & Non-goals
### Scope v1
- IMAP ingest -> OpenClaw dispatch -> callback -> SMTP reply.
- Stateful processing with SQLite.
- Thread handling via `thread_id`.
- Idempotency by `message_id`.
- Health endpoints:
- `GET /healthz` (liveness)
- `GET /readyz` (readiness)
- Retry mechanism:
- OpenClaw dispatch retries: 3 attempts with backoff 1s, 5s, 15s.
- SMTP send retries: 3 attempts with backoff 1s, 5s, 15s.
### Non-goals v1
- No rate limiting.
- No DLQ / reprocess UI.
- No advanced metrics stack.
## 2) State Machine & Schema
### Task states
- `IGNORED`
- `RECEIVED`
- `AI_PROCESSING`
- `CALLBACK_DONE`
- `SMTP_RETRYING`
- `COMPLETED`
- `FAILED`
### Retry rules
- OpenClaw dispatch failure: retry 3 times (1s, 5s, 15s). Exhausted retries -> `FAILED`.
- SMTP send failure: transition to `SMTP_RETRYING`, retry 3 times (1s, 5s, 15s). Exhausted retries -> `FAILED`.
- Persist final failure reason in `last_error`.
### SQLite schema updates (`tasks`)
- `attempt_openclaw INTEGER DEFAULT 0`
- `attempt_smtp INTEGER DEFAULT 0`
- `last_error TEXT NULL`
- `next_retry_at DATETIME NULL` (used only if retry scheduling is asynchronous)
## 3) API Contracts & Security
### OpenClaw callback contract
Required fields:
- `metadata.task_uuid`
- `result` (text)
- `status`
### Webhook authentication (KISS)
- Bridge requires header `X-Bridge-Token` on callback requests.
- Expected secret is configured in `.env` (e.g., `BRIDGE_CALLBACK_TOKEN`).
- Missing/invalid token -> `401 Unauthorized`.
### Callback idempotency
- If task already in `COMPLETED`, callback handler returns `200` and skips updates.
### Health endpoints behavior
- `GET /healthz` -> `200 {"status":"ok"}` when process is alive.
- `GET /readyz` checks:
- DB writable
- required config present (IMAP/SMTP/OpenClaw/token)
- Returns `503` on any failed check.
## 4) Acceptance Criteria
1. Valid incoming email eventually reaches `COMPLETED` and reply preserves email thread headers.
2. OpenClaw/SMTP transient failures retry with exact backoff sequence 1s, 5s, 15s.
3. Exhausted retries result in `FAILED` with `last_error` populated.
4. Anti-loop, whitelist, and idempotency behavior work as defined.
## 5) Test Matrix (v1)
1. Happy path: new mail -> valid callback -> SMTP success -> `COMPLETED`.
2. OpenClaw fails twice then succeeds -> `COMPLETED`, `attempt_openclaw=3`.
3. SMTP fails all retries -> `FAILED`, `last_error` present.
4. Callback with invalid token -> `401`, no task state change.
5. Duplicate `message_id` -> no new task created.
6. System sender or non-whitelisted sender -> `IGNORED`.
## 6) Logging (minimum)
Structured logs must include:
- `task_uuid`
- `thread_id`
- `message_id`
- `status`
- `attempt`
- `error` (when present)
+55
View File
@@ -0,0 +1,55 @@
module thuanle.me/claw-email-bridge
go 1.26.2
require (
github.com/emersion/go-imap/v2 v2.0.0-beta.8
github.com/emersion/go-message v0.18.2
github.com/gin-gonic/gin v1.12.0
github.com/glebarez/sqlite v1.11.0
github.com/google/uuid v1.3.0
github.com/joho/godotenv v1.5.1
gorm.io/gorm v1.31.1
)
require (
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
)
+155
View File
@@ -0,0 +1,155 @@
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug=
github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48=
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
+188
View File
@@ -0,0 +1,188 @@
package ai_client
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"thuanle.me/claw-email-bridge/internal/config"
"thuanle.me/claw-email-bridge/internal/database"
"thuanle.me/claw-email-bridge/internal/logging"
"gorm.io/gorm"
)
// Retry backoff schedule: 1s, 5s, 15s.
var retryBackoffs = []time.Duration{1 * time.Second, 5 * time.Second, 15 * time.Second}
// openClawRequest is the payload sent to OpenClaw API.
type openClawRequest struct {
Input string `json:"input"`
SessionID string `json:"session_id"`
History []historyEntry `json:"history,omitempty"`
CallbackURL string `json:"callback_url"`
Metadata map[string]string `json:"metadata"`
}
type historyEntry struct {
Role string `json:"role"`
Content string `json:"content"`
}
// Dispatcher sends tasks to OpenClaw and handles retry logic.
type Dispatcher struct {
cfg *config.Config
db *gorm.DB
httpClient *http.Client
}
// NewDispatcher creates a dispatcher with a direct HTTP client (no system proxy).
func NewDispatcher(cfg *config.Config, db *gorm.DB) *Dispatcher {
return &Dispatcher{
cfg: cfg,
db: db,
httpClient: &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
Proxy: nil, // Direct connection, no proxy inheritance.
},
},
}
}
// Dispatch sends a task to OpenClaw with retry.
// This should be called asynchronously (go dispatcher.Dispatch(task)).
func (d *Dispatcher) Dispatch(task *database.Task) {
log := logging.TaskLogger(task.TaskUUID, task.ThreadID, task.MessageID)
// Build history from completed tasks in the same thread.
history, err := d.buildHistory(task.ThreadID)
if err != nil {
log.Error("openclaw: failed to build history", "error", err)
// Continue without history — non-fatal.
}
// Build callback URL.
callbackURL := fmt.Sprintf("http://%s/callback", d.cfg.ListenAddr)
payload := openClawRequest{
Input: task.BodyPlain,
SessionID: task.ThreadID,
History: history,
CallbackURL: callbackURL,
Metadata: map[string]string{"task_uuid": task.TaskUUID},
}
// Update status to AI_PROCESSING before first attempt.
task.Status = database.StatusAIProcessing
if err := database.UpdateTask(d.db, task); err != nil {
log.Error("openclaw: failed to update status to AI_PROCESSING", "error", err)
return
}
// Retry loop: up to 3 attempts.
for attempt := 1; attempt <= len(retryBackoffs); attempt++ {
task.AttemptOpenClaw = attempt
log.Info("openclaw: dispatching",
"attempt", attempt,
"status", task.Status,
)
err := d.callAPI(payload)
if err == nil {
// Success — task stays AI_PROCESSING, waiting for callback.
log.Info("openclaw: dispatch successful", "attempt", attempt)
if err := database.UpdateTask(d.db, task); err != nil {
log.Error("openclaw: failed to save attempt count", "error", err)
}
return
}
log.Warn("openclaw: dispatch failed",
"attempt", attempt,
"error", err,
)
// Last attempt — mark as FAILED.
if attempt == len(retryBackoffs) {
errMsg := err.Error()
task.Status = database.StatusFailed
task.LastError = &errMsg
if err := database.UpdateTask(d.db, task); err != nil {
log.Error("openclaw: failed to save FAILED status", "error", err)
}
log.Error("openclaw: all retries exhausted",
"attempt", attempt,
"status", database.StatusFailed,
"error", errMsg,
)
return
}
// Wait before retry.
backoff := retryBackoffs[attempt-1]
log.Info("openclaw: retrying after backoff", "backoff", backoff)
time.Sleep(backoff)
}
}
// callAPI makes a single HTTP POST to OpenClaw.
func (d *Dispatcher) callAPI(payload openClawRequest) error {
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal payload: %w", err)
}
req, err := http.NewRequest(http.MethodPost, d.cfg.OpenClawURL, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
if d.cfg.OpenClawAPIKey != "" {
req.Header.Set("Authorization", "Bearer "+d.cfg.OpenClawAPIKey)
}
resp, err := d.httpClient.Do(req)
if err != nil {
return fmt.Errorf("http request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
// Read response body for error details (limit to 1KB).
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return fmt.Errorf("openclaw returned %d: %s", resp.StatusCode, string(respBody))
}
// buildHistory fetches completed tasks in the thread and formats them as history entries.
func (d *Dispatcher) buildHistory(threadID string) ([]historyEntry, error) {
tasks, err := database.ThreadHistory(d.db, threadID)
if err != nil {
return nil, err
}
var history []historyEntry
for _, t := range tasks {
// User message.
history = append(history, historyEntry{
Role: "user",
Content: t.BodyPlain,
})
// AI response.
if t.AIResponse != "" {
history = append(history, historyEntry{
Role: "assistant",
Content: t.AIResponse,
})
}
}
return history, nil
}
+114
View File
@@ -0,0 +1,114 @@
package ai_client_test
import (
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"thuanle.me/claw-email-bridge/internal/ai_client"
"thuanle.me/claw-email-bridge/internal/config"
"thuanle.me/claw-email-bridge/internal/database"
)
// Test matrix #2: OpenClaw fail 2 lần, lần 3 thành công → COMPLETED, attempt_openclaw = 3.
func TestDispatch_RetryThenSuccess(t *testing.T) {
tdb := database.NewTestDB(t)
var callCount atomic.Int32
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
n := callCount.Add(1)
if n <= 2 {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("temporary error"))
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok"}`))
}))
defer mockServer.Close()
cfg := &config.Config{
OpenClawURL: mockServer.URL,
ListenAddr: ":9999",
}
task := &database.Task{
TaskUUID: "uuid-retry-test",
ThreadID: "thread-1",
MessageID: "msg-retry@test.com",
Sender: "user@test.com",
Subject: "Retry test",
BodyPlain: "Hello",
Status: database.StatusReceived,
}
if err := database.CreateTask(tdb.DB, task); err != nil {
t.Fatalf("create task: %v", err)
}
dispatcher := ai_client.NewDispatcher(cfg, tdb.DB)
dispatcher.Dispatch(task) // Synchronous call for testing.
// Verify 3 attempts were made.
if callCount.Load() != 3 {
t.Errorf("expected 3 API calls, got %d", callCount.Load())
}
// Reload task from DB.
updated, err := database.FindByTaskUUID(tdb.DB, "uuid-retry-test")
if err != nil {
t.Fatalf("find task: %v", err)
}
if updated.Status != database.StatusAIProcessing {
t.Errorf("expected AI_PROCESSING (waiting for callback), got %s", updated.Status)
}
if updated.AttemptOpenClaw != 3 {
t.Errorf("expected attempt_openclaw = 3, got %d", updated.AttemptOpenClaw)
}
}
// OpenClaw fail all 3 retries → FAILED with last_error.
func TestDispatch_AllRetriesFailed(t *testing.T) {
tdb := database.NewTestDB(t)
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("server down"))
}))
defer mockServer.Close()
cfg := &config.Config{
OpenClawURL: mockServer.URL,
ListenAddr: ":9999",
}
task := &database.Task{
TaskUUID: "uuid-fail-all",
ThreadID: "thread-1",
MessageID: "msg-fail@test.com",
Sender: "user@test.com",
Subject: "Fail test",
BodyPlain: "Hello",
Status: database.StatusReceived,
}
if err := database.CreateTask(tdb.DB, task); err != nil {
t.Fatalf("create task: %v", err)
}
dispatcher := ai_client.NewDispatcher(cfg, tdb.DB)
dispatcher.Dispatch(task)
updated, err := database.FindByTaskUUID(tdb.DB, "uuid-fail-all")
if err != nil {
t.Fatalf("find task: %v", err)
}
if updated.Status != database.StatusFailed {
t.Errorf("expected FAILED, got %s", updated.Status)
}
if updated.LastError == nil {
t.Error("expected last_error to be set")
}
if updated.AttemptOpenClaw != 3 {
t.Errorf("expected attempt_openclaw = 3, got %d", updated.AttemptOpenClaw)
}
}
+137
View File
@@ -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"})
}
}
+198
View File
@@ -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)
}
}
+111
View File
@@ -0,0 +1,111 @@
package config
import (
"fmt"
"os"
"strings"
"github.com/joho/godotenv"
)
// Config holds all application configuration loaded from environment variables.
type Config struct {
// IMAP settings
IMAPHost string
IMAPPort string
IMAPUser string
IMAPPass string
// SMTP settings
SMTPHost string
SMTPPort string
SMTPUser string
SMTPPass string
// OpenClaw settings
OpenClawURL string
OpenClawAPIKey string
// Bridge settings
BridgeCallbackToken string
SystemEmail string
WhitelistEmails []string
// Optional
IMAPProxyURL string
// Server
ListenAddr string
}
// Load reads .env (if present) and populates Config from environment variables.
// Returns an error if any required variable is missing.
func Load() (*Config, error) {
// Best-effort load .env — ignore error if file doesn't exist.
_ = godotenv.Load()
cfg := &Config{
IMAPHost: os.Getenv("IMAP_HOST"),
IMAPPort: os.Getenv("IMAP_PORT"),
IMAPUser: os.Getenv("IMAP_USER"),
IMAPPass: os.Getenv("IMAP_PASS"),
SMTPHost: os.Getenv("SMTP_HOST"),
SMTPPort: os.Getenv("SMTP_PORT"),
SMTPUser: os.Getenv("SMTP_USER"),
SMTPPass: os.Getenv("SMTP_PASS"),
OpenClawURL: os.Getenv("OPENCLAW_URL"),
OpenClawAPIKey: os.Getenv("OPENCLAW_API_KEY"),
BridgeCallbackToken: os.Getenv("BRIDGE_CALLBACK_TOKEN"),
SystemEmail: os.Getenv("SYSTEM_EMAIL"),
IMAPProxyURL: os.Getenv("IMAP_PROXY_URL"),
ListenAddr: os.Getenv("LISTEN_ADDR"),
}
if raw := os.Getenv("WHITELIST_EMAILS"); raw != "" {
for _, email := range strings.Split(raw, ",") {
if trimmed := strings.TrimSpace(email); trimmed != "" {
cfg.WhitelistEmails = append(cfg.WhitelistEmails, trimmed)
}
}
}
if cfg.ListenAddr == "" {
cfg.ListenAddr = ":8080"
}
if err := cfg.validate(); err != nil {
return nil, err
}
return cfg, nil
}
// validate checks that all required configuration values are present.
func (c *Config) validate() error {
required := map[string]string{
"IMAP_HOST": c.IMAPHost,
"IMAP_PORT": c.IMAPPort,
"IMAP_USER": c.IMAPUser,
"IMAP_PASS": c.IMAPPass,
"SMTP_HOST": c.SMTPHost,
"SMTP_PORT": c.SMTPPort,
"SMTP_USER": c.SMTPUser,
"SMTP_PASS": c.SMTPPass,
"OPENCLAW_URL": c.OpenClawURL,
"BRIDGE_CALLBACK_TOKEN": c.BridgeCallbackToken,
"SYSTEM_EMAIL": c.SystemEmail,
}
var missing []string
for name, val := range required {
if val == "" {
missing = append(missing, name)
}
}
if len(missing) > 0 {
return fmt.Errorf("missing required config: %s", strings.Join(missing, ", "))
}
return nil
}
+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)
},
}
}
+25
View File
@@ -0,0 +1,25 @@
package logging
import (
"log/slog"
"os"
)
// Setup initialises the application-wide structured logger.
// All output goes to stdout as JSON for easy parsing by Docker / log aggregators.
func Setup() {
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
})
slog.SetDefault(slog.New(handler))
}
// TaskLogger returns a logger pre-populated with common task fields.
// Usage: logging.TaskLogger(taskUUID, threadID, messageID).Info("message", ...)
func TaskLogger(taskUUID, threadID, messageID string) *slog.Logger {
return slog.With(
"task_uuid", taskUUID,
"thread_id", threadID,
"message_id", messageID,
)
}
+362
View File
@@ -0,0 +1,362 @@
package mail
import (
"context"
"fmt"
"io"
"log/slog"
"strings"
"time"
gomessage "github.com/emersion/go-message/mail"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapclient"
"github.com/google/uuid"
"thuanle.me/claw-email-bridge/internal/config"
"thuanle.me/claw-email-bridge/internal/database"
"thuanle.me/claw-email-bridge/internal/logging"
"gorm.io/gorm"
)
// IMAPWatcher monitors an IMAP mailbox via IDLE and processes new emails.
type IMAPWatcher struct {
cfg *config.Config
db *gorm.DB
client *imapclient.Client
// onReceived is called after a task is saved as RECEIVED.
// This will be wired to the OpenClaw dispatch in a future step.
OnReceived func(task *database.Task)
}
// NewIMAPWatcher creates a new IMAP watcher.
func NewIMAPWatcher(cfg *config.Config, db *gorm.DB) *IMAPWatcher {
return &IMAPWatcher{cfg: cfg, db: db}
}
// Run connects to IMAP, selects INBOX, and enters the IDLE loop.
// It blocks until ctx is cancelled. Reconnects automatically on error.
func (w *IMAPWatcher) Run(ctx context.Context) {
for {
if err := w.connectAndWatch(ctx); err != nil {
slog.Error("imap watcher error, reconnecting in 10s", "error", err)
}
select {
case <-ctx.Done():
slog.Info("imap watcher stopped")
return
case <-time.After(10 * time.Second):
// Reconnect after delay.
}
}
}
// connectAndWatch handles a single IMAP session lifecycle.
func (w *IMAPWatcher) connectAndWatch(ctx context.Context) error {
numMessages := uint32(0)
options := &imapclient.Options{
UnilateralDataHandler: &imapclient.UnilateralDataHandler{
Mailbox: func(data *imapclient.UnilateralDataMailbox) {
if data.NumMessages != nil && *data.NumMessages > numMessages {
slog.Info("imap: new message notification", "num_messages", *data.NumMessages)
numMessages = *data.NumMessages
}
},
},
}
addr := fmt.Sprintf("%s:%s", w.cfg.IMAPHost, w.cfg.IMAPPort)
slog.Info("imap: connecting", "addr", addr)
c, err := imapclient.DialTLS(addr, options)
if err != nil {
return fmt.Errorf("dial TLS: %w", err)
}
w.client = c
defer func() {
_ = c.Close()
w.client = nil
}()
if err := c.Login(w.cfg.IMAPUser, w.cfg.IMAPPass).Wait(); err != nil {
return fmt.Errorf("login: %w", err)
}
slog.Info("imap: logged in", "user", w.cfg.IMAPUser)
selectedMbox, err := c.Select("INBOX", nil).Wait()
if err != nil {
return fmt.Errorf("select INBOX: %w", err)
}
numMessages = selectedMbox.NumMessages
slog.Info("imap: INBOX selected", "messages", numMessages)
// Fetch any unseen messages on startup.
if err := w.fetchUnseen(c); err != nil {
slog.Error("imap: initial fetch unseen failed", "error", err)
}
// IDLE loop: wait for new messages, then fetch unseen.
for {
select {
case <-ctx.Done():
return nil
default:
}
idleCmd, err := c.Idle()
if err != nil {
return fmt.Errorf("idle: %w", err)
}
done := make(chan error, 1)
go func() {
done <- idleCmd.Wait()
}()
// Re-check every 28 minutes (RFC recommends <29 min IDLE timeout).
idleTimer := time.NewTimer(28 * time.Minute)
select {
case <-ctx.Done():
_ = idleCmd.Close()
idleTimer.Stop()
return nil
case err := <-done:
idleTimer.Stop()
if err != nil {
return fmt.Errorf("idle wait: %w", err)
}
case <-idleTimer.C:
// Stop IDLE and restart it.
if err := idleCmd.Close(); err != nil {
return fmt.Errorf("idle close: %w", err)
}
<-done
}
// After exiting IDLE, fetch unseen messages.
if err := w.fetchUnseen(c); err != nil {
slog.Error("imap: fetch unseen failed", "error", err)
}
}
}
// fetchUnseen searches for UNSEEN messages and processes each one.
func (w *IMAPWatcher) fetchUnseen(c *imapclient.Client) error {
criteria := &imap.SearchCriteria{
NotFlag: []imap.Flag{imap.FlagSeen},
}
searchData, err := c.UIDSearch(criteria, nil).Wait()
if err != nil {
return fmt.Errorf("uid search: %w", err)
}
uids := searchData.AllUIDs()
if len(uids) == 0 {
return nil
}
slog.Info("imap: unseen messages found", "count", len(uids))
uidSet := imap.UIDSet{}
for _, uid := range uids {
uidSet.AddNum(uid)
}
bodySection := &imap.FetchItemBodySection{}
fetchOptions := &imap.FetchOptions{
Envelope: true,
UID: true,
BodySection: []*imap.FetchItemBodySection{bodySection},
}
fetchCmd := c.Fetch(uidSet, fetchOptions)
defer fetchCmd.Close()
for {
msg := fetchCmd.Next()
if msg == nil {
break
}
buf, err := msg.Collect()
if err != nil {
slog.Error("imap: collect message failed", "error", err)
continue
}
w.processMessage(c, buf, bodySection)
}
if err := fetchCmd.Close(); err != nil {
return fmt.Errorf("fetch close: %w", err)
}
return nil
}
// processMessage handles a single fetched email message.
func (w *IMAPWatcher) processMessage(c *imapclient.Client, buf *imapclient.FetchMessageBuffer, bodySection *imap.FetchItemBodySection) {
env := buf.Envelope
if env == nil {
slog.Warn("imap: message has no envelope, skipping")
return
}
messageID := env.MessageID
subject := env.Subject
// Extract sender email.
sender := ""
if len(env.From) > 0 {
sender = env.From[0].Addr()
}
log := logging.TaskLogger("", "", messageID)
// 1. Anti-loop: skip if sender is the system email.
if strings.EqualFold(sender, w.cfg.SystemEmail) {
log.Info("imap: anti-loop, ignoring own email", "sender", sender)
w.saveIgnored(messageID, sender, subject, "anti-loop: system email")
w.markSeen(c, buf.UID)
return
}
// 2. Whitelist: skip if sender not in allowed list.
if !w.isWhitelisted(sender) {
log.Info("imap: sender not whitelisted, ignoring", "sender", sender)
w.saveIgnored(messageID, sender, subject, "not whitelisted")
w.markSeen(c, buf.UID)
return
}
// 3. Idempotency: skip if message_id already exists.
existing, err := database.FindByMessageID(w.db, messageID)
if err != nil {
log.Error("imap: db lookup failed", "error", err)
return
}
if existing != nil {
log.Info("imap: duplicate message_id, skipping")
w.markSeen(c, buf.UID)
return
}
// 4. Threading: determine thread_id.
threadID := messageID
inReplyTo := ""
// Parse body to get In-Reply-To / References headers and plain text.
bodyPlain := ""
rawBody := buf.FindBodySection(bodySection)
if rawBody != nil {
mr, err := gomessage.CreateReader(io.NopCloser(strings.NewReader(string(rawBody))))
if err == nil {
// Extract In-Reply-To header.
if irt, err := mr.Header.Text("In-Reply-To"); err == nil && irt != "" {
inReplyTo = strings.TrimSpace(irt)
}
// Read body parts for plain text.
for {
p, err := mr.NextPart()
if err != nil {
break
}
if _, ok := p.Header.(*gomessage.InlineHeader); ok {
b, _ := io.ReadAll(p.Body)
if bodyPlain == "" {
bodyPlain = string(b)
}
}
}
}
}
if inReplyTo != "" {
parent, err := database.FindByMessageID(w.db, inReplyTo)
if err == nil && parent != nil {
threadID = parent.ThreadID
}
}
// 5. Save task with status RECEIVED.
taskUUID := uuid.New().String()
task := &database.Task{
TaskUUID: taskUUID,
ThreadID: threadID,
MessageID: messageID,
Sender: sender,
Subject: subject,
BodyPlain: bodyPlain,
Status: database.StatusReceived,
}
taskLog := logging.TaskLogger(taskUUID, threadID, messageID)
if err := database.CreateTask(w.db, task); err != nil {
taskLog.Error("imap: failed to save task", "error", err)
return
}
taskLog.Info("imap: task created",
"sender", sender,
"subject", subject,
"status", database.StatusReceived,
)
// Mark as seen in IMAP so we don't re-process.
w.markSeen(c, buf.UID)
// Trigger dispatch (will be wired later).
if w.OnReceived != nil {
w.OnReceived(task)
}
}
// saveIgnored records an ignored email in the DB for auditability.
func (w *IMAPWatcher) saveIgnored(messageID, sender, subject, reason string) {
taskUUID := uuid.New().String()
errMsg := reason
task := &database.Task{
TaskUUID: taskUUID,
ThreadID: messageID,
MessageID: messageID,
Sender: sender,
Subject: subject,
Status: database.StatusIgnored,
LastError: &errMsg,
}
if err := database.CreateTask(w.db, task); err != nil {
slog.Error("imap: failed to save ignored task", "error", err, "message_id", messageID)
}
}
// isWhitelisted checks if the sender is in the allowed list.
// Empty whitelist means all senders are allowed.
func (w *IMAPWatcher) isWhitelisted(sender string) bool {
if len(w.cfg.WhitelistEmails) == 0 {
return true
}
lower := strings.ToLower(sender)
for _, email := range w.cfg.WhitelistEmails {
if strings.ToLower(email) == lower {
return true
}
}
return false
}
// markSeen flags a message as \Seen in IMAP.
func (w *IMAPWatcher) markSeen(c *imapclient.Client, uid imap.UID) {
storeFlags := imap.StoreFlags{
Op: imap.StoreFlagsAdd,
Flags: []imap.Flag{imap.FlagSeen},
Silent: true,
}
if err := c.Store(imap.UIDSetNum(uid), &storeFlags, nil).Close(); err != nil {
slog.Error("imap: failed to mark message as seen", "error", err, "uid", uid)
}
}
+134
View File
@@ -0,0 +1,134 @@
package mail
import (
"fmt"
"net/smtp"
"strings"
"time"
"thuanle.me/claw-email-bridge/internal/config"
"thuanle.me/claw-email-bridge/internal/database"
"thuanle.me/claw-email-bridge/internal/logging"
"gorm.io/gorm"
)
// Retry backoff schedule: 1s, 5s, 15s.
var smtpRetryBackoffs = []time.Duration{1 * time.Second, 5 * time.Second, 15 * time.Second}
// SMTPSender sends reply emails via SMTP with retry logic.
type SMTPSender struct {
cfg *config.Config
db *gorm.DB
}
// NewSMTPSender creates a new SMTP sender.
func NewSMTPSender(cfg *config.Config, db *gorm.DB) *SMTPSender {
return &SMTPSender{cfg: cfg, db: db}
}
// SendReply sends an AI-generated reply to the original sender.
// This should be called asynchronously (go sender.SendReply(task)).
func (s *SMTPSender) SendReply(task *database.Task) {
log := logging.TaskLogger(task.TaskUUID, task.ThreadID, task.MessageID)
// Build the email message with threading headers.
msg := s.buildMessage(task)
for attempt := 1; attempt <= len(smtpRetryBackoffs); attempt++ {
task.AttemptSMTP = attempt
log.Info("smtp: sending reply",
"attempt", attempt,
"to", task.Sender,
)
err := s.send(task.Sender, msg)
if err == nil {
task.Status = database.StatusCompleted
if err := database.UpdateTask(s.db, task); err != nil {
log.Error("smtp: failed to save COMPLETED status", "error", err)
}
log.Info("smtp: reply sent successfully",
"attempt", attempt,
"status", database.StatusCompleted,
)
return
}
log.Warn("smtp: send failed",
"attempt", attempt,
"error", err,
)
// Update status to SMTP_RETRYING.
task.Status = database.StatusSMTPRetrying
if err := database.UpdateTask(s.db, task); err != nil {
log.Error("smtp: failed to save retry status", "error", err)
}
// Last attempt — mark as FAILED.
if attempt == len(smtpRetryBackoffs) {
errMsg := err.Error()
task.Status = database.StatusFailed
task.LastError = &errMsg
if err := database.UpdateTask(s.db, task); err != nil {
log.Error("smtp: failed to save FAILED status", "error", err)
}
log.Error("smtp: all retries exhausted",
"attempt", attempt,
"status", database.StatusFailed,
"error", errMsg,
)
return
}
backoff := smtpRetryBackoffs[attempt-1]
log.Info("smtp: retrying after backoff", "backoff", backoff)
time.Sleep(backoff)
}
}
// buildMessage constructs the RFC 2822 email with threading headers.
func (s *SMTPSender) buildMessage(task *database.Task) string {
subject := task.Subject
if !strings.HasPrefix(strings.ToLower(subject), "re:") {
subject = "Re: " + subject
}
var b strings.Builder
b.WriteString(fmt.Sprintf("From: %s\r\n", s.cfg.SystemEmail))
b.WriteString(fmt.Sprintf("To: %s\r\n", task.Sender))
b.WriteString(fmt.Sprintf("Subject: %s\r\n", subject))
// Threading headers — required by requirements 4.3.
b.WriteString(fmt.Sprintf("In-Reply-To: %s\r\n", task.MessageID))
b.WriteString(fmt.Sprintf("References: %s\r\n", task.MessageID))
b.WriteString("MIME-Version: 1.0\r\n")
b.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
b.WriteString("\r\n")
b.WriteString(task.AIResponse)
return b.String()
}
// send performs the actual SMTP delivery.
func (s *SMTPSender) send(to string, msg string) error {
addr := fmt.Sprintf("%s:%s", s.cfg.SMTPHost, s.cfg.SMTPPort)
auth := smtp.PlainAuth("", s.cfg.SMTPUser, s.cfg.SMTPPass, s.cfg.SMTPHost)
err := smtp.SendMail(addr, auth, s.cfg.SystemEmail, []string{to}, []byte(msg))
if err != nil {
return fmt.Errorf("smtp sendmail: %w", err)
}
return nil
}
// SendReplyFunc returns a function compatible with api.OnCallbackDone.
func (s *SMTPSender) SendReplyFunc() func(task *database.Task) {
return func(task *database.Task) {
s.SendReply(task)
}
}
+178
View File
@@ -0,0 +1,178 @@
PROJECT: EMAIL-OPENCLAW BRIDGE (STATEFUL, KISS)
1) Mục tiêu & phạm vi
Mục tiêu hệ thống:
- Tự động xử lý email bằng OpenClaw theo luồng:
Ingress (IMAP) -> Dispatch (OpenClaw) -> Callback -> Egress (SMTP reply).
Phạm vi v1:
- Stateful bằng SQLite.
- Hỗ trợ email thread theo `thread_id`.
- Idempotency theo `message_id`.
- Webhook callback có xác thực bằng token đơn giản.
- Bổ sung health endpoints:
- `GET /healthz` (liveness)
- `GET /readyz` (readiness)
- Retry cơ bản (KISS) cho OpenClaw và SMTP:
- Tối đa 3 lần với backoff: 1s, 5s, 15s.
Non-goals v1:
- Không rate limit.
- Không DLQ/reprocess UI.
- Không stack metrics nâng cao.
2) Công nghệ yêu cầu
- Ngôn ngữ: Go.
- Database: SQLite.
- Khuyến nghị: `gorm.io/gorm` + `modernc.org/sqlite` (không cần CGO khi build Docker).
- IMAP: `github.com/emersion/go-imap` (+ IDLE).
- SMTP: `net/smtp` hoặc thư viện gửi mail Go tương đương.
- HTTP/Webhook: `gin-gonic/gin` hoặc `gofiber/fiber`.
- Proxy IMAP (optional): `golang.org/x/net/proxy`.
- Triển khai: Docker + Docker Compose.
3) Database schema
Bảng `tasks`:
- `id` INTEGER PRIMARY KEY AUTOINCREMENT
- `task_uuid` TEXT UNIQUE
- `thread_id` TEXT
- `message_id` TEXT
- `sender` TEXT
- `subject` TEXT
- `body_plain` TEXT
- `ai_response` TEXT
- `status` TEXT
- `attempt_openclaw` INTEGER DEFAULT 0
- `attempt_smtp` INTEGER DEFAULT 0
- `last_error` TEXT NULL
- `next_retry_at` DATETIME NULL (chỉ cần nếu retry xử lý theo scheduler/worker)
- `created_at` DATETIME
- `updated_at` DATETIME
Danh sách trạng thái hợp lệ:
- `IGNORED`
- `RECEIVED`
- `AI_PROCESSING`
- `CALLBACK_DONE`
- `SMTP_RETRYING`
- `COMPLETED`
- `FAILED`
4) Luồng xử lý chi tiết
4.1 Ingress (IMAP)
- Theo dõi mailbox qua IMAP IDLE.
- Nếu có `IMAP_PROXY_URL` thì kết nối IMAP qua proxy; nếu không thì direct.
- Khi có email mới:
1. Anti-loop: nếu `sender` trùng email hệ thống -> `IGNORED`.
2. Whitelist: nếu không nằm trong danh sách cho phép -> `IGNORED`.
3. Idempotency: nếu `message_id` đã tồn tại -> bỏ qua.
4. Threading:
- Nếu có `In-Reply-To`/`References`: tìm email cha trong DB, kế thừa `thread_id`.
- Nếu không có: `thread_id = message_id`.
5. Lưu DB với `status = RECEIVED`.
4.2 Dispatch (OpenClaw)
- Lấy nội dung email hiện tại và history của cùng `thread_id` (chỉ các bản ghi `COMPLETED`).
- Gọi OpenClaw API bằng HTTP client đi direct (không kế thừa proxy hệ thống).
- Payload tối thiểu:
- `input`
- `session_id` = `thread_id`
- `history` (optional)
- `callback_url`
- `metadata.task_uuid`
- Cập nhật `status = AI_PROCESSING`.
Retry OpenClaw:
- Khi lỗi tạm thời: retry tối đa 3 lần theo backoff 1s, 5s, 15s.
- Mỗi lần retry tăng `attempt_openclaw`.
- Hết retry: `status = FAILED`, ghi `last_error`.
4.3 Callback + Egress (SMTP)
- Webhook nhận callback từ OpenClaw.
- Tìm task theo `metadata.task_uuid`.
- Nếu task đã `COMPLETED`: trả 200 và không cập nhật gì thêm (idempotent callback).
- Nếu hợp lệ: cập nhật `ai_response`, `status = CALLBACK_DONE`.
Gửi email phản hồi:
- Subject/Body theo kết quả AI.
- Bắt buộc set `In-Reply-To``References` khớp message liên quan để giữ thread phía người nhận.
Retry SMTP:
- Nếu lỗi tạm thời: chuyển `SMTP_RETRYING`, retry 3 lần theo 1s, 5s, 15s.
- Mỗi lần retry tăng `attempt_smtp`.
- Hết retry: `status = FAILED`, ghi `last_error`.
- Thành công: `status = COMPLETED`.
5) API contract & bảo mật
5.1 Callback contract (từ OpenClaw)
Required:
- `metadata.task_uuid`
- `result` (text)
- `status`
5.2 Xác thực webhook (KISS)
- Yêu cầu header: `X-Bridge-Token`.
- Giá trị kỳ vọng lấy từ `.env`, ví dụ: `BRIDGE_CALLBACK_TOKEN`.
- Thiếu/sai token -> trả `401 Unauthorized`.
5.3 Health endpoints
- `GET /healthz` -> `200 {"status":"ok"}` nếu process sống.
- `GET /readyz`:
- kiểm tra DB writable,
- kiểm tra cấu hình bắt buộc có đủ (IMAP/SMTP/OpenClaw/token),
- nếu lỗi bất kỳ check nào -> `503`.
6) Logging & observability (mức tối thiểu)
- Structured logging ra stdout.
- Mỗi log liên quan xử lý email phải có:
- `task_uuid`
- `thread_id`
- `message_id`
- `status`
- `attempt`
- `error` (nếu có)
7) Cấu hình môi trường
Đọc từ `.env`:
- `IMAP_HOST`, `IMAP_PORT`, `IMAP_USER`, `IMAP_PASS`
- `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`
- `OPENCLAW_URL`, `OPENCLAW_API_KEY` (nếu dùng)
- `BRIDGE_CALLBACK_TOKEN`
- `WHITELIST_EMAILS` (comma-separated)
- `SYSTEM_EMAIL`
- `IMAP_PROXY_URL` (optional)
Ví dụ:
- `WHITELIST_EMAILS="user1@example.com,user2@example.com"`
- `IMAP_PROXY_URL="socks5://user:pass@proxy-host:port"`
8) Docker triển khai
- Multi-stage build để tối ưu image size.
- `docker-compose.yml` mount volume cho SQLite DB (vd `database.db`).
- Xem log bằng: `docker logs -f <container_name>`.
9) Acceptance criteria (bắt buộc đạt)
1. Email hợp lệ đi hết pipeline và kết thúc `COMPLETED`.
2. Thread reply giữ đúng `In-Reply-To`/`References`.
3. OpenClaw/SMTP retry đúng thứ tự 1s -> 5s -> 15s.
4. Hết retry thì `FAILED` và có `last_error`.
5. Callback sai token trả 401 và không đổi state task.
6. Anti-loop + whitelist + idempotency hoạt động đúng.
10) Test matrix tối thiểu
1. Happy path: mail mới -> callback hợp lệ -> SMTP thành công -> `COMPLETED`.
2. OpenClaw fail 2 lần, lần 3 thành công -> `COMPLETED`, `attempt_openclaw = 3`.
3. SMTP fail hết retry -> `FAILED`, có `last_error`.
4. Callback sai token -> 401, state không đổi.
5. Duplicate `message_id` -> không tạo task mới.
6. Sender là chính hệ thống hoặc ngoài whitelist -> `IGNORED`.