Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
200bba7549 | ||
|
|
6af6da1d99 | ||
|
|
f978ed44b8 | ||
|
|
b5941e5aa1 | ||
|
|
6bbc107050 | ||
|
|
b73b399c55 | ||
|
|
6c6a6280b6 | ||
|
|
3eb64338c9 | ||
|
|
c112ac37c9 | ||
|
|
61280ba9a6 | ||
|
|
094bd35de4 | ||
|
|
c127018bc9 | ||
|
|
5e7a479991 | ||
|
|
562d3b01c3 | ||
|
|
92121278bb | ||
|
|
aa05e3f7c0 | ||
|
|
ab992abc26 | ||
|
|
8006e6d64c | ||
|
|
2233fd232a | ||
|
|
3985b9dae3 | ||
|
|
28224ccb9f | ||
|
|
668b64d594 | ||
|
|
16db5acb3a | ||
|
|
b013c68988 | ||
|
|
cb2d158689 | ||
|
|
1eb9147ba1 | ||
|
|
4145f7a50b | ||
|
|
c55a27d710 | ||
|
|
15e697ddf3 | ||
|
|
c63895f4da | ||
|
|
aebae1cea5 | ||
|
|
270b13834a | ||
|
|
4090a44524 | ||
|
|
540dbdc0e0 | ||
|
|
31b31c7635 | ||
|
|
1290e3b222 | ||
|
|
248a5bdbcd | ||
|
|
8be84cc396 |
@@ -0,0 +1,84 @@
|
||||
---
|
||||
name: implement-task
|
||||
description: Phase 2 skill. Use when issue scope is locked by Product Owner (Phase 1 done). Plan, implement, create PR, and own it until merged as Developer.
|
||||
---
|
||||
|
||||
# Implement Task
|
||||
|
||||
**Phase:** 2 — Requirement Implementation
|
||||
**Role:** Developer
|
||||
|
||||
## When to invoke
|
||||
|
||||
- User says: "implement issue #X", "/implement-task #X"
|
||||
- Phase 1 is complete (issue has a "Final Requirements" comment from PO).
|
||||
|
||||
## Prerequisite
|
||||
|
||||
- Issue must have a **Final Requirements** comment locked by PO. If not found, stop and tell the user to run `/validate-issue` first.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Load issue**
|
||||
- Read issue: `mcp__gitea__.issue_read` (`method: "get"`).
|
||||
- Read comments, find the **Final Requirements** comment: `mcp__gitea__.issue_read` (`method: "get_comments"`).
|
||||
- If Final Requirements not found → stop, tell user to validate issue first.
|
||||
- Read current Gitea user: `mcp__gitea__.get_me`.
|
||||
- If current user is not already in the issue assignee list, update the issue with `mcp__gitea__.issue_write` (`method: "update"`) and set `assignees` to the existing assignees plus the current username.
|
||||
|
||||
2. **Read codebase**
|
||||
- Use `mcp__gitea__.get_file_contents`, `mcp__gitea__.get_repository_tree` to understand current state.
|
||||
- Identify files and functions that need to change.
|
||||
|
||||
3. **Write plan and post on issue**
|
||||
- List implementation steps with verification criteria.
|
||||
- List files to change.
|
||||
- Flag risks or tradeoffs.
|
||||
- Post plan as comment: `mcp__gitea__.issue_write` (`method: "add_comment"`).
|
||||
- **Wait for "Approved" before coding.** Stop here.
|
||||
|
||||
4. **Implement** (only after plan is approved)
|
||||
- Create feature branch from `main`:
|
||||
```
|
||||
git checkout main && git pull origin main
|
||||
git checkout -b feat/<short-description>
|
||||
```
|
||||
- If currently on another branch, use worktree:
|
||||
```
|
||||
git worktree add .worktrees/<branch-name> main
|
||||
```
|
||||
- Implement following the plan (surgical changes only).
|
||||
- Run validation:
|
||||
```
|
||||
gofmt -l .
|
||||
go vet ./...
|
||||
go test ./...
|
||||
```
|
||||
|
||||
5. **Create PR**
|
||||
- Use `mcp__gitea__.pull_request_write` (`method: "create"`).
|
||||
- PR body must include:
|
||||
- Summary of changes
|
||||
- Test evidence (exact commands and output)
|
||||
- `Fixes #<issue-id>`
|
||||
- Add reviewer.
|
||||
|
||||
6. **Own the PR until merged**
|
||||
- Monitor for review feedback: `mcp__gitea__.pull_request_read` (`method: "get_reviews"`, `get_review_comments`).
|
||||
- When feedback arrives:
|
||||
- Checkout the PR branch (or use worktree if on another branch).
|
||||
- Fix code locally, commit, push.
|
||||
- Reply on PR: `mcp__gitea__.pull_request_review_write` (`method: "create"`, `state: "COMMENT"`) explaining what was fixed.
|
||||
- Never edit remote files directly via API.
|
||||
- After reviewer approves (APPROVED), merge the PR if user confirms.
|
||||
|
||||
## Rules
|
||||
- Use Gitea MCP tools for all issue/PR operations.
|
||||
- Preserve existing assignees when adding yourself unless the user explicitly asks to replace them.
|
||||
- Never edit remote files directly — always work on local branch.
|
||||
- Never claim completion without fresh verification output.
|
||||
- Keep changes strictly scoped to the approved plan.
|
||||
- If the change needs durable documentation, create/update `docs/adr/**`. Do not commit `docs/superpowers/**` planning artifacts.
|
||||
|
||||
## Next skill
|
||||
After PR is created, the user invokes `/review-pr <pr-number>` on the Reviewer agent.
|
||||
@@ -0,0 +1,118 @@
|
||||
---
|
||||
name: review-pr
|
||||
description: Phase 2 skill. Use when a PR is created or updated. Review PR against locked issue requirements and post verdict as Reviewer. Do NOT edit code on the PR.
|
||||
---
|
||||
|
||||
# Review PR
|
||||
|
||||
**Phase:** 2 — Requirement Implementation
|
||||
**Role:** Reviewer
|
||||
|
||||
## When to invoke
|
||||
|
||||
- User says: "review PR #X", "check PR #X", "/review-pr #X"
|
||||
- A PR is created or updated and needs review.
|
||||
|
||||
## Prerequisite
|
||||
|
||||
- PR exists on Gitea.
|
||||
- The referenced issue has a **Final Requirements** comment from PO (Phase 1 done).
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Load PR and issue**
|
||||
- Read PR: `mcp__gitea__.pull_request_read` (`method: "get"`).
|
||||
- If PR is already merged or closed, say so and stop.
|
||||
- Read current Gitea user: `mcp__gitea__.get_me`.
|
||||
- Read existing reviews: `mcp__gitea__.pull_request_read` (`method: "get_reviews"`).
|
||||
- If the current user is not already requested as a reviewer and has not already reviewed this PR, add them with `mcp__gitea__.pull_request_write` (`method: "add_reviewers"`).
|
||||
- Identify the linked issue from PR body (`Fixes #<id>` or `Closes #<id>`).
|
||||
- Read issue comments, find the **Final Requirements** comment: `mcp__gitea__.issue_read` (`method: "get_comments"`).
|
||||
- Read PR discussion comments: `mcp__gitea__.issue_read` (`method: "get_comments"`), using the PR number as the index.
|
||||
|
||||
2. **Read review history** (for re-reviews)
|
||||
- Get review comments: `mcp__gitea__.pull_request_read` (`method: "get_review_comments"`).
|
||||
- Treat reviews, inline review comments, and PR discussion comments as separate context sources.
|
||||
- If author addressed feedback in a follow-up commit, focus on new commits but still sanity-check the full diff.
|
||||
|
||||
3. **Get the diff**
|
||||
- Use `mcp__gitea__.pull_request_read` (`method: "get_diff"`).
|
||||
- Use `mcp__gitea__.get_file_contents` for surrounding context when needed.
|
||||
|
||||
4. **Validate locally** (when needed)
|
||||
- Create worktree:
|
||||
```
|
||||
git worktree add .worktrees/pr-<number> origin/<head-branch>
|
||||
```
|
||||
- Run checks:
|
||||
```
|
||||
gofmt -l .
|
||||
go vet ./...
|
||||
go test ./...
|
||||
```
|
||||
- Remove worktree when done:
|
||||
```
|
||||
git worktree remove .worktrees/pr-<number>
|
||||
```
|
||||
|
||||
5. **Evaluate**
|
||||
- Does PR address **all** Final Requirements from the issue?
|
||||
- Does PR contain unrelated changes or refactoring? → FAIL immediately.
|
||||
- Code correctness, style, test coverage.
|
||||
|
||||
6. **Write verdict**
|
||||
- Start with **PASS** or **FAIL**.
|
||||
- Include file:line references for findings.
|
||||
- Explain concrete impact, not style preferences.
|
||||
|
||||
7. **Post review**
|
||||
- Post proactively if verdict is clear.
|
||||
- Use `mcp__gitea__.pull_request_review_write` (`method: "create"`):
|
||||
- `PASS` → `state: "APPROVED"`
|
||||
- `FAIL` → `state: "REQUEST_CHANGES"`
|
||||
- Non-blocking note → `state: "COMMENT"`
|
||||
- For plain discussion (not changing review state): `mcp__gitea__.issue_write` (`method: "add_comment"`).
|
||||
|
||||
## Review Format
|
||||
|
||||
### PASS
|
||||
```
|
||||
PASS
|
||||
|
||||
No blocking issues found.
|
||||
|
||||
Validation:
|
||||
- gofmt -l .: OK
|
||||
- go vet ./...: OK
|
||||
- go test ./...: OK
|
||||
|
||||
Non-blocking:
|
||||
- <optional note>
|
||||
```
|
||||
|
||||
### FAIL
|
||||
```
|
||||
FAIL
|
||||
|
||||
1. [High] <issue with path:line and impact>
|
||||
2. [Medium] <issue with path:line and impact>
|
||||
|
||||
Validation:
|
||||
- go test ./...: FAIL (<details>)
|
||||
```
|
||||
|
||||
## Rules
|
||||
- Use Gitea MCP tools for all PR/review operations.
|
||||
- Do not add yourself as reviewer twice; check requested reviewers and existing reviews first.
|
||||
- Do NOT edit code on the PR.
|
||||
- FAIL if PR contains unrelated changes or scope creep.
|
||||
- Post review proactively if verdict is clear.
|
||||
- Do not post a verdict until PR discussion comments have been checked.
|
||||
- Mention validation commands you actually ran.
|
||||
- No bracketed author/tool tags.
|
||||
- Accept `docs/adr/**` when it records a direct, durable decision tied to the issue scope.
|
||||
- Treat `docs/superpowers/**` and other agent execution artifacts as scope creep. When rejecting docs, explain whether the content should be deleted entirely or rewritten as an ADR.
|
||||
|
||||
## What happens next
|
||||
- **PASS (APPROVED):** Developer merges the PR.
|
||||
- **FAIL (REQUEST_CHANGES):** Developer fixes feedback, then user re-invokes `/review-pr` on this PR.
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
name: validate-issue
|
||||
description: Phase 1 skill. Use when a Gitea issue is created or updated and needs requirement validation. Analyze, clarify, and lock scope as Product Owner. Do NOT implement.
|
||||
---
|
||||
|
||||
# Validate Issue
|
||||
|
||||
**Phase:** 1 — Requirement Refinement
|
||||
**Role:** Product Owner
|
||||
|
||||
## When to invoke
|
||||
|
||||
- User says: "check issue #X", "validate issue #X", "/validate-issue #X"
|
||||
- A new issue is created or an existing issue is updated with new information.
|
||||
|
||||
## Prerequisite
|
||||
|
||||
- Issue exists on Gitea.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Load issue**
|
||||
- Read issue: `mcp__gitea__.issue_read` (`method: "get"`).
|
||||
- Read comments: `mcp__gitea__.issue_read` (`method: "get_comments"`).
|
||||
|
||||
2. **Analyze**
|
||||
- Is the problem statement clear?
|
||||
- Is the scope well-defined or too broad?
|
||||
- Is it technically feasible?
|
||||
- If needed, read relevant code: `mcp__gitea__.get_file_contents`.
|
||||
|
||||
3. **Decide**
|
||||
- **Unclear or incomplete:**
|
||||
- Post clarifying questions: `mcp__gitea__.issue_write` (`method: "add_comment"`).
|
||||
- Stop. Wait for user to respond, then re-run this skill.
|
||||
- **Clear and feasible:**
|
||||
- Post Final Requirements (see format below).
|
||||
- If the change likely needs a durable ADR, add a note: "ADR recommended: this change locks in a behavior/contract that future work must preserve."
|
||||
- This locks the scope and signals Phase 2 can begin.
|
||||
|
||||
## Final Requirements format
|
||||
|
||||
```
|
||||
## Final Requirements
|
||||
|
||||
**Summary:** <one-line summary>
|
||||
|
||||
**Scope:**
|
||||
- <requirement 1>
|
||||
- <requirement 2>
|
||||
|
||||
**Out of scope:**
|
||||
- <explicitly excluded items>
|
||||
|
||||
**Acceptance criteria:**
|
||||
- <verifiable check 1>
|
||||
- <verifiable check 2>
|
||||
```
|
||||
|
||||
## Rules
|
||||
- Use Gitea MCP tools for all operations.
|
||||
- Do NOT write code, create branches, or create PRs.
|
||||
- Do NOT proceed to Phase 2 — that is a separate skill.
|
||||
|
||||
## Next skill
|
||||
After Final Requirements are posted, the user invokes `/implement-task <issue-id>` on the Developer agent.
|
||||
@@ -18,6 +18,7 @@ OPENCLAW_API_KEY=
|
||||
BRIDGE_CALLBACK_TOKEN=a-strong-random-token
|
||||
SYSTEM_EMAIL=bridge@example.com
|
||||
WHITELIST_EMAILS=user1@example.com,user2@example.com
|
||||
BLOCKLIST_EMAILS=
|
||||
|
||||
# === Optional ===
|
||||
IMAP_PROXY_URL=
|
||||
|
||||
@@ -27,3 +27,22 @@ jobs:
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- run: go test ./...
|
||||
|
||||
vet:
|
||||
runs-on: linux
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- run: go vet ./...
|
||||
|
||||
staticcheck:
|
||||
runs-on: linux
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- run: go install honnef.co/go/tools/cmd/staticcheck@2025.1.1
|
||||
- run: $(go env GOPATH)/bin/staticcheck ./...
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
# Agent Instructions
|
||||
|
||||
## 1. Coding Principles
|
||||
|
||||
### 1.1 Simplicity First
|
||||
|
||||
- No out-of-scope features.
|
||||
- No abstractions for single-use code.
|
||||
- If 50 lines work instead of 200, write 50.
|
||||
|
||||
### 1.2 Surgical Changes
|
||||
|
||||
- Only change lines directly related to the request.
|
||||
- Never refactor adjacent code unless asked.
|
||||
- Remove orphaned imports/variables/functions your changes created.
|
||||
|
||||
### 1.3 Goal-Driven Execution
|
||||
|
||||
- Define success criteria before coding.
|
||||
- Verify each step before moving to the next.
|
||||
|
||||
---
|
||||
|
||||
## 2. Workflow Overview
|
||||
|
||||
All work starts from a Gitea issue. Two phases, each with a dedicated role:
|
||||
|
||||
1. **Phase 1 — Requirement Refinement** (Product Owner): Clarify and lock scope.
|
||||
2. **Phase 2 — Requirement Implementation** (Developer & Reviewer): Plan, code, review, merge.
|
||||
|
||||
---
|
||||
|
||||
## 3. Phase 1: Requirement Refinement
|
||||
|
||||
**Role:** Product Owner (PO)
|
||||
**Goal:** Turn a raw issue into a clear, scoped, feasible requirement.
|
||||
**Constraint:** PO must NOT write code or create PRs.
|
||||
|
||||
### Steps
|
||||
|
||||
1. Read and analyze the issue.
|
||||
2. Is the requirement clear and well-scoped?
|
||||
- **No:** Comment clarifying questions on the issue. Stop and wait for user response.
|
||||
- **Yes:** Comment a summary and lock the scope (Final Requirements).
|
||||
3. Locked issue moves to Phase 2.
|
||||
|
||||
---
|
||||
|
||||
## 4. Phase 2: Requirement Implementation
|
||||
|
||||
**Roles:** Developer (Dev) and Reviewer (Rev)
|
||||
**Goal:** Implement the locked requirement from Phase 1.
|
||||
|
||||
### 4.1 Developer: Plan & Implement
|
||||
|
||||
1. Read the locked issue and any clarification comments.
|
||||
2. Read relevant code to understand current state.
|
||||
3. Write an implementation plan — list steps, files to change, risks.
|
||||
4. **Comment the plan on the issue. Wait for "Approved" before coding.**
|
||||
5. Once approved:
|
||||
- Create a feature branch from `main`.
|
||||
- Implement following the plan (surgical changes only).
|
||||
- Run validation: `go test ./...`, `go vet ./...`, `gofmt -l .`.
|
||||
- Create PR with `Fixes #<issue>` in the body so Gitea auto-closes the issue on merge.
|
||||
- After merge, verify the issue is closed. If not (e.g. missing keyword), close it manually with a summary comment.
|
||||
6. **Own the PR until merged:**
|
||||
- Monitor for review feedback.
|
||||
- Fix feedback on the correct branch (checkout or worktree, never edit remote via API).
|
||||
- Always reply on PR after pushing a fix.
|
||||
|
||||
### 4.2 Reviewer: Review PR
|
||||
|
||||
**Constraint:** Reviewer must NOT edit code on the PR.
|
||||
|
||||
1. Re-read the original issue — verify PR addresses the locked requirement.
|
||||
2. Read PR diff, review history, and PR discussion comments.
|
||||
3. Validate in worktree if needed (`.worktrees/pr-<number>`, run `go test`, `go vet`, `gofmt`).
|
||||
4. Check for scope creep — FAIL if PR contains unrelated changes or refactoring.
|
||||
5. Write verdict — start with **PASS** or **FAIL**.
|
||||
6. Post review proactively if verdict is clear:
|
||||
- `PASS` → `APPROVED`
|
||||
- `FAIL` → `REQUEST_CHANGES`
|
||||
- Non-blocking note → `COMMENT`
|
||||
|
||||
---
|
||||
|
||||
## 5. Gitea Tools
|
||||
|
||||
- Always use Gitea MCP tools in this project.
|
||||
- Scope: read PR/issue, list comments, post replies, create/edit PRs, reviews.
|
||||
- Use `tea` CLI only as fallback when MCP is unavailable.
|
||||
- Local CLI tools (`git`, `go`, `rg`) are fine for local validation.
|
||||
|
||||
---
|
||||
|
||||
## 6. Review Format
|
||||
|
||||
```
|
||||
PASS
|
||||
|
||||
No blocking issues found.
|
||||
|
||||
Validation:
|
||||
- go test ./...: OK
|
||||
- go vet ./...: OK
|
||||
|
||||
Non-blocking:
|
||||
- <optional note>
|
||||
```
|
||||
|
||||
```
|
||||
FAIL
|
||||
|
||||
1. [High] <issue with path:line and impact>
|
||||
2. [Medium] <issue with path:line and impact>
|
||||
|
||||
Validation:
|
||||
- go test ./...: OK
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Documentation Rules
|
||||
|
||||
- Durable architecture or behavior decisions belong in `docs/adr/**`.
|
||||
- `docs/superpowers/**` is for agent execution artifacts and must not be merged as part of product implementation PRs.
|
||||
- If a PR needs a lasting technical rationale, author an ADR in `docs/adr/` instead.
|
||||
- Reviewers should treat temporary plan/spec docs as scope creep, but accept ADRs that directly document the accepted behavior of the change.
|
||||
|
||||
---
|
||||
|
||||
## 8. Comment Style
|
||||
|
||||
- Use readable multiline markdown.
|
||||
- Use short sections (Summary, Changes, Test Plan).
|
||||
- Concise bullet points and explicit line breaks.
|
||||
- No bracketed author/tool tags.
|
||||
@@ -1,70 +1,5 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
|
||||
See [AGENT.md](AGENT.md) for full project instructions and workflow.
|
||||
|
||||
**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.
|
||||
Agent skills are stored in `.codex/skills`
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Tự động xử lý email bằng OpenClaw AI theo luồng:
|
||||
|
||||
```
|
||||
Ingress (IMAP) → Dispatch (OpenClaw) → Callback → Egress (SMTP reply)
|
||||
Ingress (IMAP) → Safety Checks → External Rules → Apply Context → Dispatch (OpenClaw) → Callback → Egress (SMTP reply)
|
||||
```
|
||||
|
||||
## Tính năng
|
||||
@@ -12,6 +12,7 @@ Ingress (IMAP) → Dispatch (OpenClaw) → Callback → Egress (SMTP reply)
|
||||
- **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
|
||||
- **External Rules** — pipeline configurable: whitelist, blocklist, và rule metadata cho dispatch
|
||||
- **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
|
||||
|
||||
|
||||
+17
-1
@@ -3,10 +3,12 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"thuanle.me/claw-email-bridge/internal/ai_client"
|
||||
"thuanle.me/claw-email-bridge/internal/api"
|
||||
@@ -62,16 +64,30 @@ func main() {
|
||||
// Start HTTP server with SMTP egress on callback.
|
||||
smtpSender := mail.NewSMTPSender(cfg, db)
|
||||
router := api.NewRouter(db, cfg.BridgeCallbackToken, smtpSender.SendReplyFunc())
|
||||
srv := &http.Server{Addr: cfg.ListenAddr, Handler: router}
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
slog.Info("starting HTTP server", "addr", cfg.ListenAddr)
|
||||
if err := router.Run(cfg.ListenAddr); err != nil {
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
slog.Error("server exited with error", "error", err)
|
||||
cancel()
|
||||
}
|
||||
}()
|
||||
|
||||
// Graceful shutdown: wait for context cancel then shutdown HTTP server.
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-ctx.Done()
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer shutdownCancel()
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
slog.Error("server shutdown error", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
slog.Info("bridge stopped")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0001"
|
||||
title: "IMAP thread resolution prefers In-Reply-To and falls back to References"
|
||||
status: active
|
||||
date: 2026-04-28
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
The IMAP ingress flow stores a `thread_id` before dispatching work to OpenClaw. Previously the bridge only used `In-Reply-To` to find a parent task. Some valid email clients preserve thread ancestry through `References` even when `In-Reply-To` is absent. In those cases, the bridge created a new `thread_id`, which broke conversation continuity and downstream session reuse.
|
||||
|
||||
This decision needs to be durable because future refactors of mail parsing, dispatch, or storage must preserve the same thread-resolution behavior.
|
||||
|
||||
## Decision
|
||||
|
||||
The bridge resolves parent thread linkage by trying `In-Reply-To` first. If no parent task is found, it falls back to `References`, scanning message IDs from last to first and using the first match found in the database. If neither header yields a known parent, the message's own `message_id` becomes the new `thread_id`.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Use `In-Reply-To` first, then `References` last-to-first** (chosen): preserves existing behavior, matches common email ancestry ordering, and favors the most recent known parent.
|
||||
- **Use only `In-Reply-To`**: simpler, but breaks valid threads when clients omit `In-Reply-To`.
|
||||
- **Search `References` from first to last**: may attach to an older ancestor even when a more recent known parent exists.
|
||||
- **Normalize all thread linkage into a more complex conversation graph**: more flexible, but out of scope for the current bridge behavior.
|
||||
|
||||
## Consequences
|
||||
|
||||
Thread continuity is improved for replies that rely on `References`.
|
||||
Future contributors have an explicit behavioral contract for threading semantics.
|
||||
Mail parsing changes must preserve the header precedence and reverse scan behavior unless a new ADR supersedes this one.
|
||||
The current approach still depends on existing message IDs being present in the database; if upstream behavior changes significantly, this decision may need reevaluation.
|
||||
|
||||
## Advice
|
||||
|
||||
If a future change wants different precedence rules or deeper conversation reconstruction, create a new ADR rather than silently changing this behavior.
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0002"
|
||||
title: "Ingress uses a configurable external rules pipeline before dispatch"
|
||||
status: active
|
||||
date: 2026-04-28
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
The IMAP ingress flow previously hardcoded whitelist and idempotency checks directly in processMessage. There was no extension point for operator-defined policy such as sender blocking or context-based routing. The flow mixed internal safety rules (anti-loop) with configurable policy (whitelist).
|
||||
|
||||
## Decision
|
||||
|
||||
Separate internal safety checks (anti-loop, idempotency) from external configurable rules. External rules run in a pipeline after safety checks. Each rule implements a `Rule` interface that evaluates `EmailContext` and returns `RuleResult` (accept/reject + metadata). Rule metadata is persisted as `DispatchContext` on the Task and passed to OpenClaw dispatch.
|
||||
|
||||
Pipeline order: anti-loop (internal) → idempotency (internal) → external rules → threading → save → dispatch.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Pipeline with Rule interface** (chosen): extensible, testable, each rule is isolated.
|
||||
- **Keep everything in processMessage**: simpler but not configurable or testable.
|
||||
- **Plugin-based rules via config files**: more flexible but over-engineered for current needs.
|
||||
|
||||
## Consequences
|
||||
|
||||
New rules can be added by implementing the Rule interface and registering in config.
|
||||
Whitelist and blocklist are now env-configurable and independently testable.
|
||||
Future rules (domain routing, context selection) fit the same pipeline.
|
||||
Task model has a new DispatchContext column (auto-migrated).
|
||||
@@ -0,0 +1,76 @@
|
||||
# Architecture Decision Records
|
||||
|
||||
This directory contains Architecture Decision Records (ADRs) for durable technical decisions in this repository.
|
||||
|
||||
## Purpose
|
||||
|
||||
ADRs capture cross-cutting or behavior-defining decisions that future contributors need to preserve.
|
||||
|
||||
Use an ADR when a change introduces or locks in a decision such as:
|
||||
|
||||
- runtime behavior that future work must preserve
|
||||
- repository-wide technical policy
|
||||
- architecture or integration contracts
|
||||
- storage or threading semantics
|
||||
- quality gate or workflow policy
|
||||
|
||||
Do not use ADRs for:
|
||||
|
||||
- temporary implementation plans
|
||||
- agent execution checklists
|
||||
- one-off debugging notes
|
||||
- PR-local task breakdowns
|
||||
|
||||
## Format
|
||||
|
||||
Each ADR is a markdown file with YAML frontmatter.
|
||||
|
||||
```md
|
||||
---
|
||||
type: ADR
|
||||
id: "NNNN"
|
||||
title: "Short decision title"
|
||||
status: active
|
||||
date: YYYY-MM-DD
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Why does this decision need to exist now?
|
||||
|
||||
## Decision
|
||||
|
||||
**State the decision clearly in one or two sentences.**
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A** (chosen): description with pros and cons
|
||||
- **Option B**: description with pros and cons
|
||||
- **Option C**: description with pros and cons
|
||||
|
||||
## Consequences
|
||||
|
||||
What becomes easier?
|
||||
What becomes harder?
|
||||
What risks were accepted?
|
||||
What would trigger re-evaluation?
|
||||
|
||||
## Advice
|
||||
|
||||
Optional. Capture important external input or review notes.
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- One decision per file.
|
||||
- Name files `NNNN-short-kebab-title.md`.
|
||||
- Once active, do not rewrite an ADR's meaning later. Supersede it with a new ADR instead.
|
||||
- Keep the index below aligned with the current files in this directory.
|
||||
- Do not keep placeholder or mock entries in the index.
|
||||
|
||||
## Index
|
||||
|
||||
| ID | Title | Status |
|
||||
|----|-------|--------|
|
||||
| 0001 | IMAP thread resolution prefers In-Reply-To and falls back to References | active |
|
||||
| 0002 | Ingress uses a configurable external rules pipeline before dispatch | active |
|
||||
@@ -73,7 +73,7 @@ func (d *Dispatcher) Dispatch(task *database.Task) {
|
||||
SessionID: task.ThreadID,
|
||||
History: history,
|
||||
CallbackURL: callbackURL,
|
||||
Metadata: map[string]string{"task_uuid": task.TaskUUID},
|
||||
Metadata: buildDispatchMetadata(task),
|
||||
}
|
||||
|
||||
// Update status to AI_PROCESSING before first attempt.
|
||||
@@ -195,3 +195,25 @@ func (d *Dispatcher) buildHistory(threadID string) ([]historyEntry, error) {
|
||||
|
||||
return history, nil
|
||||
}
|
||||
|
||||
// ExportBuildDispatchMetadata exports buildDispatchMetadata for testing.
|
||||
func ExportBuildDispatchMetadata(task *database.Task) map[string]string {
|
||||
return buildDispatchMetadata(task)
|
||||
}
|
||||
|
||||
// buildDispatchMetadata constructs the metadata map for OpenClaw dispatch,
|
||||
// merging the task UUID with any DispatchContext from the rules pipeline.
|
||||
func buildDispatchMetadata(task *database.Task) map[string]string {
|
||||
metadata := map[string]string{"task_uuid": task.TaskUUID}
|
||||
if task.DispatchContext != "" {
|
||||
var ctx map[string]string
|
||||
if err := json.Unmarshal([]byte(task.DispatchContext), &ctx); err == nil {
|
||||
for k, v := range ctx {
|
||||
if k != "task_uuid" {
|
||||
metadata[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
@@ -12,6 +12,53 @@ import (
|
||||
"thuanle.me/claw-email-bridge/internal/database"
|
||||
)
|
||||
|
||||
func TestBuildDispatchMetadata_ForwardsContext(t *testing.T) {
|
||||
task := &database.Task{
|
||||
TaskUUID: "uuid-ctx-test",
|
||||
DispatchContext: `{"rule":"whitelist","source":"env"}`,
|
||||
}
|
||||
metadata := ai_client.ExportBuildDispatchMetadata(task)
|
||||
|
||||
if metadata["task_uuid"] != "uuid-ctx-test" {
|
||||
t.Errorf("expected task_uuid=uuid-ctx-test, got %q", metadata["task_uuid"])
|
||||
}
|
||||
if metadata["rule"] != "whitelist" {
|
||||
t.Errorf("expected rule=whitelist, got %q", metadata["rule"])
|
||||
}
|
||||
if metadata["source"] != "env" {
|
||||
t.Errorf("expected source=env, got %q", metadata["source"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDispatchMetadata_ProtectsTaskUUID(t *testing.T) {
|
||||
task := &database.Task{
|
||||
TaskUUID: "original-uuid",
|
||||
DispatchContext: `{"task_uuid":"malicious-override","extra":"data"}`,
|
||||
}
|
||||
metadata := ai_client.ExportBuildDispatchMetadata(task)
|
||||
|
||||
if metadata["task_uuid"] != "original-uuid" {
|
||||
t.Errorf("task_uuid should not be overwritten, got %q", metadata["task_uuid"])
|
||||
}
|
||||
if metadata["extra"] != "data" {
|
||||
t.Errorf("expected extra=data, got %q", metadata["extra"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDispatchMetadata_NoContext(t *testing.T) {
|
||||
task := &database.Task{
|
||||
TaskUUID: "uuid-no-ctx",
|
||||
}
|
||||
metadata := ai_client.ExportBuildDispatchMetadata(task)
|
||||
|
||||
if len(metadata) != 1 {
|
||||
t.Errorf("expected 1 key, got %d", len(metadata))
|
||||
}
|
||||
if metadata["task_uuid"] != "uuid-no-ctx" {
|
||||
t.Errorf("expected task_uuid=uuid-no-ctx, got %q", metadata["task_uuid"])
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -31,6 +31,7 @@ type Config struct {
|
||||
BridgeCallbackToken string
|
||||
SystemEmail string
|
||||
WhitelistEmails []string
|
||||
BlocklistEmails []string
|
||||
|
||||
// Optional
|
||||
IMAPProxyURL string
|
||||
@@ -74,6 +75,14 @@ func Load() (*Config, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if raw := os.Getenv("BLOCKLIST_EMAILS"); raw != "" {
|
||||
for _, email := range strings.Split(raw, ",") {
|
||||
if trimmed := strings.TrimSpace(email); trimmed != "" {
|
||||
cfg.BlocklistEmails = append(cfg.BlocklistEmails, trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.ListenAddr == "" {
|
||||
cfg.ListenAddr = ":8080"
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ type Task struct {
|
||||
Sender string
|
||||
Subject string
|
||||
BodyPlain string
|
||||
DispatchContext string // JSON metadata from external rules pipeline.
|
||||
AIResponse string
|
||||
Status string `gorm:"index;not null"`
|
||||
AttemptOpenClaw int `gorm:"default:0"`
|
||||
|
||||
+187
-58
@@ -1,12 +1,16 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -21,6 +25,7 @@ import (
|
||||
"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/rules"
|
||||
)
|
||||
|
||||
// IMAPWatcher monitors an IMAP mailbox via IDLE and processes new emails.
|
||||
@@ -32,6 +37,8 @@ type IMAPWatcher struct {
|
||||
dialIMAP func(addr string, options *imapclient.Options) (*imapclient.Client, error)
|
||||
dialIMAPViaProxy func(addr, proxyURL string, options *imapclient.Options) (*imapclient.Client, error)
|
||||
|
||||
rules *rules.Pipeline
|
||||
|
||||
// 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)
|
||||
@@ -55,13 +62,26 @@ func (d timeoutContextDialer) DialContext(ctx context.Context, network, address
|
||||
|
||||
var socks5DialerFactory = proxy.SOCKS5
|
||||
|
||||
var proxyTLSConfigForTest = func(host string) *tls.Config {
|
||||
return &tls.Config{ServerName: host}
|
||||
}
|
||||
|
||||
// NewIMAPWatcher creates a new IMAP watcher.
|
||||
func NewIMAPWatcher(cfg *config.Config, db *gorm.DB) *IMAPWatcher {
|
||||
var externalRules []rules.Rule
|
||||
if len(cfg.WhitelistEmails) > 0 {
|
||||
externalRules = append(externalRules, rules.NewWhitelistRule(cfg.WhitelistEmails))
|
||||
}
|
||||
if len(cfg.BlocklistEmails) > 0 {
|
||||
externalRules = append(externalRules, rules.NewBlocklistRule(cfg.BlocklistEmails))
|
||||
}
|
||||
|
||||
return &IMAPWatcher{
|
||||
cfg: cfg,
|
||||
db: db,
|
||||
dialIMAP: imapclient.DialTLS,
|
||||
dialIMAPViaProxy: dialTLSViaSOCKS5,
|
||||
dialIMAPViaProxy: dialTLSViaProxy,
|
||||
rules: rules.NewPipeline(externalRules),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,34 +200,20 @@ func (w *IMAPWatcher) connectAndWatch(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
func dialTLSViaSOCKS5(addr, proxyURL string, options *imapclient.Options) (*imapclient.Client, error) {
|
||||
func dialTLSViaProxy(addr, proxyURL string, options *imapclient.Options) (*imapclient.Client, error) {
|
||||
u, err := url.Parse(proxyURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse proxy url: %w", err)
|
||||
}
|
||||
if u.Scheme != "socks5" {
|
||||
return nil, fmt.Errorf("unsupported proxy scheme: %s", u.Scheme)
|
||||
}
|
||||
|
||||
var auth *proxy.Auth
|
||||
if u.User != nil {
|
||||
pw, _ := u.User.Password()
|
||||
auth = &proxy.Auth{User: u.User.Username(), Password: pw}
|
||||
}
|
||||
|
||||
dialer, err := socks5DialerFactory("tcp", u.Host, auth, timeoutContextDialer{timeout: imapDialTimeout})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create socks5 dialer: %w", err)
|
||||
}
|
||||
|
||||
dialCtx, dialCancel := context.WithTimeout(context.Background(), imapDialTimeout)
|
||||
defer dialCancel()
|
||||
|
||||
var conn net.Conn
|
||||
if cd, ok := dialer.(proxy.ContextDialer); ok {
|
||||
conn, err = cd.DialContext(dialCtx, "tcp", addr)
|
||||
} else {
|
||||
conn, err = dialer.Dial("tcp", addr)
|
||||
switch u.Scheme {
|
||||
case "socks5":
|
||||
conn, err = dialViaSOCKS5(u, addr)
|
||||
case "http", "https":
|
||||
conn, err = dialViaCONNECT(u, addr)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported proxy scheme: %s", u.Scheme)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("proxy dial: %w", err)
|
||||
@@ -230,6 +236,96 @@ func dialTLSViaSOCKS5(addr, proxyURL string, options *imapclient.Options) (*imap
|
||||
return imapclient.New(tlsConn, options), nil
|
||||
}
|
||||
|
||||
func dialViaSOCKS5(u *url.URL, targetAddr string) (net.Conn, error) {
|
||||
var auth *proxy.Auth
|
||||
if u.User != nil {
|
||||
pw, _ := u.User.Password()
|
||||
auth = &proxy.Auth{User: u.User.Username(), Password: pw}
|
||||
}
|
||||
|
||||
dialer, err := socks5DialerFactory("tcp", u.Host, auth, timeoutContextDialer{timeout: imapDialTimeout})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create socks5 dialer: %w", err)
|
||||
}
|
||||
|
||||
dialCtx, dialCancel := context.WithTimeout(context.Background(), imapDialTimeout)
|
||||
defer dialCancel()
|
||||
|
||||
if cd, ok := dialer.(proxy.ContextDialer); ok {
|
||||
return cd.DialContext(dialCtx, "tcp", targetAddr)
|
||||
}
|
||||
return dialer.Dial("tcp", targetAddr)
|
||||
}
|
||||
|
||||
// proxyConn wraps a net.Conn, draining buffered data from a bufio.Reader
|
||||
// before delegating reads to the underlying connection.
|
||||
type proxyConn struct {
|
||||
net.Conn
|
||||
reader io.Reader
|
||||
}
|
||||
|
||||
func (c *proxyConn) Read(b []byte) (int, error) {
|
||||
return c.reader.Read(b)
|
||||
}
|
||||
|
||||
// dialToProxy is the function used to establish a TCP connection to the proxy.
|
||||
var dialToProxy = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return (&net.Dialer{Timeout: imapDialTimeout}).DialContext(ctx, network, addr)
|
||||
}
|
||||
|
||||
func dialViaCONNECT(u *url.URL, targetAddr string) (net.Conn, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), imapDialTimeout)
|
||||
defer cancel()
|
||||
|
||||
conn, err := dialToProxy(ctx, "tcp", u.Host)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dial proxy %s: %w", u.Host, err)
|
||||
}
|
||||
|
||||
if u.Scheme == "https" {
|
||||
tlsConn := tls.Client(conn, proxyTLSConfigForTest(u.Hostname()))
|
||||
if err := tlsConn.HandshakeContext(ctx); err != nil {
|
||||
_ = tlsConn.Close()
|
||||
return nil, fmt.Errorf("tls handshake to proxy: %w", err)
|
||||
}
|
||||
conn = tlsConn
|
||||
}
|
||||
|
||||
_ = conn.SetDeadline(time.Now().Add(imapDialTimeout))
|
||||
|
||||
connectReq := fmt.Sprintf("CONNECT %s HTTP/1.1\r\nHost: %s\r\n", targetAddr, targetAddr)
|
||||
if u.User != nil {
|
||||
username := u.User.Username()
|
||||
password, _ := u.User.Password()
|
||||
creds := base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
|
||||
connectReq += fmt.Sprintf("Proxy-Authorization: Basic %s\r\n", creds)
|
||||
}
|
||||
connectReq += "\r\n"
|
||||
|
||||
if _, err := fmt.Fprint(conn, connectReq); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("send connect: %w", err)
|
||||
}
|
||||
|
||||
br := bufio.NewReader(conn)
|
||||
resp, err := http.ReadResponse(br, nil)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("read connect response: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("proxy connect failed: %s", resp.Status)
|
||||
}
|
||||
|
||||
_ = conn.SetDeadline(time.Time{})
|
||||
|
||||
if br.Buffered() > 0 {
|
||||
return &proxyConn{Conn: conn, reader: io.MultiReader(br, conn)}, nil
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// fetchUnseen searches for UNSEEN messages and processes each one.
|
||||
func (w *IMAPWatcher) fetchUnseen(c *imapclient.Client) error {
|
||||
criteria := &imap.SearchCriteria{
|
||||
@@ -283,6 +379,39 @@ func (w *IMAPWatcher) fetchUnseen(c *imapclient.Client) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveThreadID determines the thread_id for a message.
|
||||
// It tries In-Reply-To first, then falls back to References (last-to-first).
|
||||
// Returns the message's own ID if no parent is found.
|
||||
func resolveThreadID(messageID, inReplyTo, references string, lookup func(string) (*database.Task, error)) string {
|
||||
if inReplyTo != "" {
|
||||
if parent, err := lookup(inReplyTo); err == nil && parent != nil {
|
||||
return parent.ThreadID
|
||||
}
|
||||
}
|
||||
|
||||
for _, refID := range parseReferences(references) {
|
||||
if parent, err := lookup(refID); err == nil && parent != nil {
|
||||
return parent.ThreadID
|
||||
}
|
||||
}
|
||||
|
||||
return messageID
|
||||
}
|
||||
|
||||
// parseReferences splits a References header value into individual message IDs,
|
||||
// returned in reverse order (most recent first).
|
||||
func parseReferences(header string) []string {
|
||||
if header == "" {
|
||||
return nil
|
||||
}
|
||||
raw := strings.Fields(header)
|
||||
// Reverse: last (most recent) first.
|
||||
for i, j := 0, len(raw)-1; i < j; i, j = i+1, j-1 {
|
||||
raw[i], raw[j] = raw[j], raw[i]
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// processMessage handles a single fetched email message.
|
||||
func (w *IMAPWatcher) processMessage(c *imapclient.Client, buf *imapclient.FetchMessageBuffer, bodySection *imap.FetchItemBodySection) {
|
||||
env := buf.Envelope
|
||||
@@ -310,15 +439,7 @@ func (w *IMAPWatcher) processMessage(c *imapclient.Client, buf *imapclient.Fetch
|
||||
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.
|
||||
// 2. 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)
|
||||
@@ -330,9 +451,23 @@ func (w *IMAPWatcher) processMessage(c *imapclient.Client, buf *imapclient.Fetch
|
||||
return
|
||||
}
|
||||
|
||||
// 3. External rules: evaluate operator-configurable policy.
|
||||
ruleResult := w.rules.Evaluate(rules.EmailContext{
|
||||
Sender: sender,
|
||||
Subject: subject,
|
||||
MessageID: messageID,
|
||||
})
|
||||
if !ruleResult.Accepted {
|
||||
log.Info("imap: rejected by external rules", "sender", sender, "reason", ruleResult.Reason)
|
||||
w.saveIgnored(messageID, sender, subject, ruleResult.Reason)
|
||||
w.markSeen(c, buf.UID)
|
||||
return
|
||||
}
|
||||
|
||||
// 4. Threading: determine thread_id.
|
||||
threadID := messageID
|
||||
inReplyTo := ""
|
||||
references := ""
|
||||
|
||||
// Parse body to get In-Reply-To / References headers and plain text.
|
||||
bodyPlain := ""
|
||||
@@ -340,10 +475,12 @@ func (w *IMAPWatcher) processMessage(c *imapclient.Client, buf *imapclient.Fetch
|
||||
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)
|
||||
}
|
||||
if refs, err := mr.Header.Text("References"); err == nil && refs != "" {
|
||||
references = refs
|
||||
}
|
||||
|
||||
// Read body parts for plain text.
|
||||
for {
|
||||
@@ -361,23 +498,21 @@ func (w *IMAPWatcher) processMessage(c *imapclient.Client, buf *imapclient.Fetch
|
||||
}
|
||||
}
|
||||
|
||||
if inReplyTo != "" {
|
||||
parent, err := database.FindByMessageID(w.db, inReplyTo)
|
||||
if err == nil && parent != nil {
|
||||
threadID = parent.ThreadID
|
||||
}
|
||||
}
|
||||
threadID = resolveThreadID(messageID, inReplyTo, references, func(mid string) (*database.Task, error) {
|
||||
return database.FindByMessageID(w.db, mid)
|
||||
})
|
||||
|
||||
// 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,
|
||||
TaskUUID: taskUUID,
|
||||
ThreadID: threadID,
|
||||
MessageID: messageID,
|
||||
Sender: sender,
|
||||
Subject: subject,
|
||||
BodyPlain: bodyPlain,
|
||||
DispatchContext: marshalDispatchContext(ruleResult.Metadata),
|
||||
Status: database.StatusReceived,
|
||||
}
|
||||
|
||||
taskLog := logging.TaskLogger(taskUUID, threadID, messageID)
|
||||
@@ -420,19 +555,13 @@ func (w *IMAPWatcher) saveIgnored(messageID, sender, subject, reason string) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// marshalDispatchContext serializes rule metadata to JSON.
|
||||
func marshalDispatchContext(metadata map[string]string) string {
|
||||
if len(metadata) == 0 {
|
||||
return ""
|
||||
}
|
||||
lower := strings.ToLower(sender)
|
||||
for _, email := range w.cfg.WhitelistEmails {
|
||||
if strings.ToLower(email) == lower {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
data, _ := json.Marshal(metadata)
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// markSeen flags a message as \Seen in IMAP.
|
||||
|
||||
+213
-6
@@ -1,10 +1,20 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -73,13 +83,22 @@ func TestConnectAndWatch_UsesProxyDial_WhenProxySet(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialTLSViaSOCKS5_RejectsNonSocks5Scheme(t *testing.T) {
|
||||
_, err := dialTLSViaSOCKS5("imap.example.com:993", "http://proxy:8080", nil)
|
||||
func TestDialTLSViaProxy_RejectsUnsupportedScheme(t *testing.T) {
|
||||
_, err := dialTLSViaProxy("imap.example.com:993", "ftp://proxy:21", nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "unsupported proxy scheme") {
|
||||
t.Fatalf("expected unsupported scheme error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialTLSViaProxy_RejectsMalformedURL(t *testing.T) {
|
||||
_, err := dialTLSViaProxy("imap.example.com:993", "://bad", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for malformed URL")
|
||||
}
|
||||
}
|
||||
|
||||
// --- SOCKS5 path ---
|
||||
|
||||
type fakeContextDialer struct{}
|
||||
|
||||
func (fakeContextDialer) Dial(network, address string) (net.Conn, error) {
|
||||
@@ -93,7 +112,7 @@ func (fakeContextDialer) DialContext(ctx context.Context, network, address strin
|
||||
return nil, errors.New("used dialcontext")
|
||||
}
|
||||
|
||||
func TestDialTLSViaSOCKS5_UsesDialContextWithTimeout(t *testing.T) {
|
||||
func TestDialTLSViaProxy_SOCKS5_UsesDialContextWithTimeout(t *testing.T) {
|
||||
orig := socks5DialerFactory
|
||||
t.Cleanup(func() { socks5DialerFactory = orig })
|
||||
|
||||
@@ -104,7 +123,7 @@ func TestDialTLSViaSOCKS5_UsesDialContextWithTimeout(t *testing.T) {
|
||||
return fakeContextDialer{}, nil
|
||||
}
|
||||
|
||||
_, err := dialTLSViaSOCKS5("imap.example.com:993", "socks5://127.0.0.1:1080", nil)
|
||||
_, err := dialTLSViaProxy("imap.example.com:993", "socks5://127.0.0.1:1080", nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "proxy dial: used dialcontext") {
|
||||
t.Fatalf("expected DialContext path, got %v", err)
|
||||
}
|
||||
@@ -143,7 +162,7 @@ func (d contextConnDialer) DialContext(ctx context.Context, network, address str
|
||||
return d.conn, nil
|
||||
}
|
||||
|
||||
func TestDialTLSViaSOCKS5_SetsDeadlineForTLSHandshake(t *testing.T) {
|
||||
func TestDialTLSViaProxy_SOCKS5_SetsDeadlineForTLSHandshake(t *testing.T) {
|
||||
orig := socks5DialerFactory
|
||||
t.Cleanup(func() { socks5DialerFactory = orig })
|
||||
|
||||
@@ -152,7 +171,7 @@ func TestDialTLSViaSOCKS5_SetsDeadlineForTLSHandshake(t *testing.T) {
|
||||
return contextConnDialer{conn: conn}, nil
|
||||
}
|
||||
|
||||
_, err := dialTLSViaSOCKS5("imap.example.com:993", "socks5://127.0.0.1:1080", nil)
|
||||
_, err := dialTLSViaProxy("imap.example.com:993", "socks5://127.0.0.1:1080", nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "tls handshake") {
|
||||
t.Fatalf("expected tls handshake error, got %v", err)
|
||||
}
|
||||
@@ -160,3 +179,191 @@ func TestDialTLSViaSOCKS5_SetsDeadlineForTLSHandshake(t *testing.T) {
|
||||
t.Fatal("expected TLS handshake deadline to be set")
|
||||
}
|
||||
}
|
||||
|
||||
// --- HTTP CONNECT path ---
|
||||
|
||||
func startFakeProxy(t *testing.T, handler func(host string) bool) (addr string, cleanup func()) {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go func(c net.Conn) {
|
||||
defer c.Close()
|
||||
br := bufio.NewReader(c)
|
||||
req, err := http.ReadRequest(br)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if handler(req.URL.Host) {
|
||||
fmt.Fprintf(c, "HTTP/1.1 200 OK\r\n\r\n")
|
||||
} else {
|
||||
fmt.Fprintf(c, "HTTP/1.1 403 Forbidden\r\n\r\n")
|
||||
}
|
||||
}(conn)
|
||||
}
|
||||
}()
|
||||
return ln.Addr().String(), func() {
|
||||
ln.Close()
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialTLSViaProxy_HTTPConnect_ReachesTLSHandshake(t *testing.T) {
|
||||
addr, cleanup := startFakeProxy(t, func(host string) bool { return true })
|
||||
defer cleanup()
|
||||
|
||||
_, err := dialTLSViaProxy("imap.example.com:993", fmt.Sprintf("http://%s", addr), nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "tls handshake") {
|
||||
t.Fatalf("expected tls handshake error (CONNECT succeeded), got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialTLSViaProxy_HTTPConnect_RejectsOnProxyFailure(t *testing.T) {
|
||||
addr, cleanup := startFakeProxy(t, func(host string) bool { return false })
|
||||
defer cleanup()
|
||||
|
||||
_, err := dialTLSViaProxy("imap.example.com:993", fmt.Sprintf("http://%s", addr), nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "proxy connect failed") {
|
||||
t.Fatalf("expected proxy connect failed error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialTLSViaProxy_HTTPConnect_SendsProxyAuth(t *testing.T) {
|
||||
var gotAuth string
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
go func() {
|
||||
defer ln.Close()
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
br := bufio.NewReader(conn)
|
||||
req, err := http.ReadRequest(br)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
gotAuth = req.Header.Get("Proxy-Authorization")
|
||||
fmt.Fprintf(conn, "HTTP/1.1 200 OK\r\n\r\n")
|
||||
}()
|
||||
|
||||
_, _ = dialTLSViaProxy("imap.example.com:993",
|
||||
fmt.Sprintf("http://testuser:testpass@%s", ln.Addr().String()), nil)
|
||||
|
||||
expected := "Basic " + base64.StdEncoding.EncodeToString([]byte("testuser:testpass"))
|
||||
if gotAuth != expected {
|
||||
t.Fatalf("expected auth %q, got %q", expected, gotAuth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialTLSViaProxy_HTTPConnect_DialError(t *testing.T) {
|
||||
origDial := dialToProxy
|
||||
t.Cleanup(func() { dialToProxy = origDial })
|
||||
|
||||
dialToProxy = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return nil, errors.New("dial refused")
|
||||
}
|
||||
|
||||
_, err := dialTLSViaProxy("imap.example.com:993", "http://127.0.0.1:1", nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "dial proxy") {
|
||||
t.Fatalf("expected dial proxy error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- HTTPS CONNECT path ---
|
||||
|
||||
func generateTestCert(t *testing.T) tls.Certificate {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
DNSNames: []string{"127.0.0.1"},
|
||||
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
||||
}
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatalf("create cert: %v", err)
|
||||
}
|
||||
cert, err := x509.ParseCertificate(certDER)
|
||||
if err != nil {
|
||||
t.Fatalf("parse cert: %v", err)
|
||||
}
|
||||
return tls.Certificate{
|
||||
Certificate: [][]byte{certDER},
|
||||
PrivateKey: key,
|
||||
Leaf: cert,
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialTLSViaProxy_HTTPSConnect_ReachesTLSHandshake(t *testing.T) {
|
||||
cert := generateTestCert(t)
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
tlsLn := tls.NewListener(ln, &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
})
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for {
|
||||
conn, err := tlsLn.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go func(c net.Conn) {
|
||||
defer c.Close()
|
||||
br := bufio.NewReader(c)
|
||||
req, err := http.ReadRequest(br)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if req.URL.Host != "" {
|
||||
fmt.Fprintf(c, "HTTP/1.1 200 OK\r\n\r\n")
|
||||
}
|
||||
}(conn)
|
||||
}
|
||||
}()
|
||||
defer func() {
|
||||
tlsLn.Close()
|
||||
<-done
|
||||
}()
|
||||
|
||||
// Override dialToProxy to return raw TCP, but inject InsecureSkipVerify
|
||||
// so the TLS handshake to proxy succeeds with the test cert.
|
||||
origDial := dialToProxy
|
||||
t.Cleanup(func() { dialToProxy = origDial })
|
||||
|
||||
origProxyTLS := proxyTLSConfigForTest
|
||||
t.Cleanup(func() { proxyTLSConfigForTest = origProxyTLS })
|
||||
|
||||
proxyTLSConfigForTest = func(host string) *tls.Config {
|
||||
return &tls.Config{ServerName: host, InsecureSkipVerify: true}
|
||||
}
|
||||
|
||||
_, err = dialTLSViaProxy("imap.example.com:993",
|
||||
fmt.Sprintf("https://%s", ln.Addr().String()), nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "tls handshake") {
|
||||
t.Fatalf("expected tls handshake error (HTTPS CONNECT succeeded), got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"thuanle.me/claw-email-bridge/internal/database"
|
||||
)
|
||||
|
||||
func TestResolveThreadID_NoHeaders_ReturnsMessageID(t *testing.T) {
|
||||
got := resolveThreadID("msg-1", "", "", nilLookup)
|
||||
if got != "msg-1" {
|
||||
t.Fatalf("expected msg-1, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveThreadID_InReplyToMatches_ReturnsParentThreadID(t *testing.T) {
|
||||
lookup := func(messageID string) (*database.Task, error) {
|
||||
if messageID == "<parent>" {
|
||||
return &database.Task{ThreadID: "thread-abc"}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
got := resolveThreadID("msg-2", "<parent>", "", lookup)
|
||||
if got != "thread-abc" {
|
||||
t.Fatalf("expected thread-abc, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveThreadID_ReferencesOnly_LastIDMatches(t *testing.T) {
|
||||
lookup := func(messageID string) (*database.Task, error) {
|
||||
if messageID == "<C>" {
|
||||
return &database.Task{ThreadID: "thread-c"}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
got := resolveThreadID("msg-3", "", "<A> <B> <C>", lookup)
|
||||
if got != "thread-c" {
|
||||
t.Fatalf("expected thread-c, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveThreadID_ReferencesOnly_MiddleIDMatches(t *testing.T) {
|
||||
lookup := func(messageID string) (*database.Task, error) {
|
||||
if messageID == "<B>" {
|
||||
return &database.Task{ThreadID: "thread-b"}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
got := resolveThreadID("msg-4", "", "<A> <B> <C>", lookup)
|
||||
if got != "thread-b" {
|
||||
t.Fatalf("expected thread-b, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveThreadID_ReferencesOnly_NoMatch_ReturnsMessageID(t *testing.T) {
|
||||
got := resolveThreadID("msg-5", "", "<X> <Y>", nilLookup)
|
||||
if got != "msg-5" {
|
||||
t.Fatalf("expected msg-5, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveThreadID_InReplyToMiss_ReferencesMatches(t *testing.T) {
|
||||
lookup := func(messageID string) (*database.Task, error) {
|
||||
if messageID == "<ref>" {
|
||||
return &database.Task{ThreadID: "thread-ref"}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
got := resolveThreadID("msg-6", "<unknown>", "<ref>", lookup)
|
||||
if got != "thread-ref" {
|
||||
t.Fatalf("expected thread-ref, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveThreadID_LookupError_ReturnsMessageID(t *testing.T) {
|
||||
lookup := func(messageID string) (*database.Task, error) {
|
||||
return nil, errors.New("db error")
|
||||
}
|
||||
got := resolveThreadID("msg-7", "<parent>", "", lookup)
|
||||
if got != "msg-7" {
|
||||
t.Fatalf("expected msg-7, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
var nilLookup = func(string) (*database.Task, error) { return nil, nil }
|
||||
@@ -0,0 +1,26 @@
|
||||
package rules
|
||||
|
||||
import "strings"
|
||||
|
||||
// BlocklistRule rejects emails from configured senders.
|
||||
type BlocklistRule struct {
|
||||
emails map[string]bool
|
||||
}
|
||||
|
||||
// NewBlocklistRule creates a blocklist rule from a list of email addresses.
|
||||
func NewBlocklistRule(emails []string) *BlocklistRule {
|
||||
m := make(map[string]bool, len(emails))
|
||||
for _, e := range emails {
|
||||
m[strings.ToLower(e)] = true
|
||||
}
|
||||
return &BlocklistRule{emails: m}
|
||||
}
|
||||
|
||||
func (r *BlocklistRule) Name() string { return "blocklist" }
|
||||
|
||||
func (r *BlocklistRule) Evaluate(ctx EmailContext) RuleResult {
|
||||
if r.emails[strings.ToLower(ctx.Sender)] {
|
||||
return Reject("blocked sender")
|
||||
}
|
||||
return Accept()
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package rules
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBlocklistRule_SenderBlocked_Rejects(t *testing.T) {
|
||||
r := NewBlocklistRule([]string{"spam@test.com"})
|
||||
result := r.Evaluate(EmailContext{Sender: "spam@test.com"})
|
||||
if result.Accepted {
|
||||
t.Fatal("expected reject for blocked sender")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlocklistRule_SenderNotBlocked_Accepts(t *testing.T) {
|
||||
r := NewBlocklistRule([]string{"spam@test.com"})
|
||||
result := r.Evaluate(EmailContext{Sender: "ok@test.com"})
|
||||
if !result.Accepted {
|
||||
t.Fatal("expected accept for non-blocked sender")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlocklistRule_EmptyList_Accepts(t *testing.T) {
|
||||
r := NewBlocklistRule(nil)
|
||||
result := r.Evaluate(EmailContext{Sender: "anyone@test.com"})
|
||||
if !result.Accepted {
|
||||
t.Fatal("empty blocklist should accept all")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlocklistRule_CaseInsensitive(t *testing.T) {
|
||||
r := NewBlocklistRule([]string{"Spam@Test.com"})
|
||||
result := r.Evaluate(EmailContext{Sender: "spam@test.com"})
|
||||
if result.Accepted {
|
||||
t.Fatal("blocklist should be case-insensitive")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package rules
|
||||
|
||||
// Pipeline evaluates a sequence of rules, stopping on first rejection.
|
||||
type Pipeline struct {
|
||||
rules []Rule
|
||||
}
|
||||
|
||||
// NewPipeline creates a rules pipeline from the given rules.
|
||||
func NewPipeline(rules []Rule) *Pipeline {
|
||||
return &Pipeline{rules: rules}
|
||||
}
|
||||
|
||||
// Evaluate runs all rules in order. Returns the first rejection or a merged accept.
|
||||
func (p *Pipeline) Evaluate(ctx EmailContext) RuleResult {
|
||||
merged := Accept()
|
||||
for _, r := range p.rules {
|
||||
result := r.Evaluate(ctx)
|
||||
if !result.Accepted {
|
||||
return result
|
||||
}
|
||||
for k, v := range result.Metadata {
|
||||
if merged.Metadata == nil {
|
||||
merged.Metadata = map[string]string{k: v}
|
||||
} else {
|
||||
merged.Metadata[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package rules
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPipeline_NoRules_Accepts(t *testing.T) {
|
||||
p := NewPipeline(nil)
|
||||
result := p.Evaluate(EmailContext{Sender: "anyone@test.com"})
|
||||
if !result.Accepted {
|
||||
t.Fatal("expected accept with no rules")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPipeline_AllRulesAccept_Accepts(t *testing.T) {
|
||||
allowAll := &stubRule{name: "allow-all", result: Accept()}
|
||||
p := NewPipeline([]Rule{allowAll})
|
||||
result := p.Evaluate(EmailContext{Sender: "a@b.com"})
|
||||
if !result.Accepted {
|
||||
t.Fatal("expected accept")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPipeline_OneRuleRejects_Rejects(t *testing.T) {
|
||||
rejector := &stubRule{name: "rejector", result: Reject("nope")}
|
||||
p := NewPipeline([]Rule{rejector})
|
||||
result := p.Evaluate(EmailContext{Sender: "a@b.com"})
|
||||
if result.Accepted {
|
||||
t.Fatal("expected reject")
|
||||
}
|
||||
if result.Reason != "nope" {
|
||||
t.Fatalf("expected reason 'nope', got %s", result.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPipeline_MergesMetadata(t *testing.T) {
|
||||
r1 := &stubRule{name: "r1", result: Accept().WithMetadata("a", "1")}
|
||||
r2 := &stubRule{name: "r2", result: Accept().WithMetadata("b", "2")}
|
||||
p := NewPipeline([]Rule{r1, r2})
|
||||
result := p.Evaluate(EmailContext{})
|
||||
if result.Metadata["a"] != "1" {
|
||||
t.Fatal("missing metadata a")
|
||||
}
|
||||
if result.Metadata["b"] != "2" {
|
||||
t.Fatal("missing metadata b")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPipeline_StopsOnFirstReject(t *testing.T) {
|
||||
rejector := &stubRule{name: "rejector", result: Reject("stop")}
|
||||
neverCalled := &trackingRule{}
|
||||
p := NewPipeline([]Rule{rejector, neverCalled})
|
||||
p.Evaluate(EmailContext{})
|
||||
if neverCalled.called {
|
||||
t.Fatal("expected second rule not to be called after rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhitelistRule_EmptyList_Accepts(t *testing.T) {
|
||||
r := NewWhitelistRule(nil)
|
||||
result := r.Evaluate(EmailContext{Sender: "anyone@test.com"})
|
||||
if !result.Accepted {
|
||||
t.Fatal("empty whitelist should accept all")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhitelistRule_SenderInList_Accepts(t *testing.T) {
|
||||
r := NewWhitelistRule([]string{"allowed@test.com"})
|
||||
result := r.Evaluate(EmailContext{Sender: "allowed@test.com"})
|
||||
if !result.Accepted {
|
||||
t.Fatal("expected accept for whitelisted sender")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhitelistRule_SenderNotInList_Rejects(t *testing.T) {
|
||||
r := NewWhitelistRule([]string{"allowed@test.com"})
|
||||
result := r.Evaluate(EmailContext{Sender: "unknown@test.com"})
|
||||
if result.Accepted {
|
||||
t.Fatal("expected reject for non-whitelisted sender")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhitelistRule_CaseInsensitive(t *testing.T) {
|
||||
r := NewWhitelistRule([]string{"Allowed@Test.com"})
|
||||
result := r.Evaluate(EmailContext{Sender: "allowed@test.com"})
|
||||
if !result.Accepted {
|
||||
t.Fatal("whitelist should be case-insensitive")
|
||||
}
|
||||
}
|
||||
|
||||
// stubs
|
||||
|
||||
type stubRule struct {
|
||||
name string
|
||||
result RuleResult
|
||||
}
|
||||
|
||||
func (s *stubRule) Name() string { return s.name }
|
||||
func (s *stubRule) Evaluate(_ EmailContext) RuleResult { return s.result }
|
||||
|
||||
type trackingRule struct {
|
||||
called bool
|
||||
}
|
||||
|
||||
func (t *trackingRule) Name() string { return "tracker" }
|
||||
func (t *trackingRule) Evaluate(_ EmailContext) RuleResult { t.called = true; return Accept() }
|
||||
@@ -0,0 +1,41 @@
|
||||
package rules
|
||||
|
||||
// EmailContext contains the data available to rules for evaluation.
|
||||
type EmailContext struct {
|
||||
Sender string
|
||||
Subject string
|
||||
MessageID string
|
||||
}
|
||||
|
||||
// RuleResult is the output of evaluating a single rule.
|
||||
type RuleResult struct {
|
||||
Accepted bool
|
||||
Reason string
|
||||
Metadata map[string]string
|
||||
}
|
||||
|
||||
// Accept returns a passing RuleResult.
|
||||
func Accept() RuleResult {
|
||||
return RuleResult{Accepted: true}
|
||||
}
|
||||
|
||||
// Reject returns a failing RuleResult with a reason.
|
||||
func Reject(reason string) RuleResult {
|
||||
return RuleResult{Accepted: false, Reason: reason}
|
||||
}
|
||||
|
||||
// WithMetadata attaches a key-value pair to the result.
|
||||
func (r RuleResult) WithMetadata(key, value string) RuleResult {
|
||||
if r.Metadata == nil {
|
||||
r.Metadata = map[string]string{key: value}
|
||||
} else {
|
||||
r.Metadata[key] = value
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// Rule evaluates an email against operator-configurable policy.
|
||||
type Rule interface {
|
||||
Name() string
|
||||
Evaluate(ctx EmailContext) RuleResult
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package rules
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRuleResult_Accept(t *testing.T) {
|
||||
r := Accept()
|
||||
if !r.Accepted {
|
||||
t.Fatal("expected Accepted=true")
|
||||
}
|
||||
if r.Reason != "" {
|
||||
t.Fatal("expected no reason for accept")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuleResult_Reject(t *testing.T) {
|
||||
r := Reject("blocked sender")
|
||||
if r.Accepted {
|
||||
t.Fatal("expected Accepted=false")
|
||||
}
|
||||
if r.Reason != "blocked sender" {
|
||||
t.Fatalf("expected reason 'blocked sender', got %s", r.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuleResult_WithMetadata(t *testing.T) {
|
||||
r := Accept().WithMetadata("routing", "high-priority")
|
||||
if r.Metadata["routing"] != "high-priority" {
|
||||
t.Fatalf("expected routing=high-priority, got %s", r.Metadata["routing"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailContext_Fields(t *testing.T) {
|
||||
ctx := EmailContext{
|
||||
Sender: "user@example.com",
|
||||
Subject: "Test",
|
||||
}
|
||||
if ctx.Sender != "user@example.com" {
|
||||
t.Fatal("sender mismatch")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package rules
|
||||
|
||||
import "strings"
|
||||
|
||||
// WhitelistRule accepts emails from configured senders.
|
||||
// An empty list accepts all senders.
|
||||
type WhitelistRule struct {
|
||||
emails map[string]bool
|
||||
}
|
||||
|
||||
// NewWhitelistRule creates a whitelist rule from a list of email addresses.
|
||||
func NewWhitelistRule(emails []string) *WhitelistRule {
|
||||
m := make(map[string]bool, len(emails))
|
||||
for _, e := range emails {
|
||||
m[strings.ToLower(e)] = true
|
||||
}
|
||||
return &WhitelistRule{emails: m}
|
||||
}
|
||||
|
||||
func (r *WhitelistRule) Name() string { return "whitelist" }
|
||||
|
||||
func (r *WhitelistRule) Evaluate(ctx EmailContext) RuleResult {
|
||||
if len(r.emails) == 0 {
|
||||
return Accept()
|
||||
}
|
||||
if r.emails[strings.ToLower(ctx.Sender)] {
|
||||
return Accept()
|
||||
}
|
||||
return Reject("not whitelisted")
|
||||
}
|
||||
+11
-5
@@ -47,6 +47,7 @@ Bảng `tasks`:
|
||||
- `status` TEXT
|
||||
- `attempt_openclaw` INTEGER DEFAULT 0
|
||||
- `attempt_smtp` INTEGER DEFAULT 0
|
||||
- `dispatch_context` TEXT (JSON metadata từ external rules pipeline, truyền qua OpenClaw dispatch)
|
||||
- `last_error` TEXT NULL
|
||||
- `next_retry_at` DATETIME NULL (chỉ cần nếu retry xử lý theo scheduler/worker)
|
||||
- `created_at` DATETIME
|
||||
@@ -68,12 +69,12 @@ Danh sách trạng thái hợp lệ:
|
||||
- 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.
|
||||
2. Idempotency: nếu `message_id` đã tồn tại -> bỏ qua.
|
||||
3. External rules: chạy pipeline các rule configurable (whitelist, blocklist, v.v.). Nếu reject -> `IGNORED`. Rule metadata được lưu vào `dispatch_context`.
|
||||
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`.
|
||||
5. Lưu DB với `status = RECEIVED`, bao gồm `dispatch_context` từ rules.
|
||||
|
||||
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`).
|
||||
@@ -84,6 +85,7 @@ Danh sách trạng thái hợp lệ:
|
||||
- `history` (optional)
|
||||
- `callback_url`
|
||||
- `metadata.task_uuid`
|
||||
- `metadata.*` từ `dispatch_context` (rule-derived context)
|
||||
- Cập nhật `status = AI_PROCESSING`.
|
||||
|
||||
Retry OpenClaw:
|
||||
@@ -146,6 +148,7 @@ Required:
|
||||
- `OPENCLAW_URL`, `OPENCLAW_API_KEY` (nếu dùng)
|
||||
- `BRIDGE_CALLBACK_TOKEN`
|
||||
- `WHITELIST_EMAILS` (comma-separated)
|
||||
- `BLOCKLIST_EMAILS` (comma-separated, optional)
|
||||
- `SYSTEM_EMAIL`
|
||||
- `IMAP_PROXY_URL` (optional)
|
||||
|
||||
@@ -166,7 +169,7 @@ Ví dụ:
|
||||
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.
|
||||
6. Anti-loop + idempotency + external rules hoạt động đúng thứ tự.
|
||||
|
||||
10) Test matrix tối thiểu
|
||||
|
||||
@@ -175,4 +178,7 @@ Ví dụ:
|
||||
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`.
|
||||
6. Sender là chính hệ thống -> `IGNORED`.
|
||||
7. Sender ngoài whitelist -> `IGNORED` bởi external rules.
|
||||
8. Sender trong blocklist -> `IGNORED` bởi external rules.
|
||||
9. Rule metadata được truyền qua dispatch context đến OpenClaw.
|
||||
|
||||
Reference in New Issue
Block a user