Author SHA1 Message Date
thuanle c112ac37c9 docs: add design spec for issue 18 CI checks
Record the approved design to add go vet and pinned staticcheck jobs to PR CI with minimal workflow changes.
2026-04-28 07:25:52 +07:00
thuanleandClaude Opus 4.7 094bd35de4 style: gofmt client_test.go
CI / fmt (pull_request) Successful in 4m46s
CI / test (pull_request) Successful in 12m58s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 07:12:49 +07:00
thuanleandClaude Opus 4.7 c127018bc9 fix: add dispatch_context to schema docs and test buildDispatchMetadata
CI / fmt (pull_request) Failing after 4m45s
CI / test (pull_request) Successful in 10m47s
- Add dispatch_context column to requirements.md task schema section
- Export buildDispatchMetadata for test coverage
- Add tests: context forwarding, task_uuid overwrite protection, empty context

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 07:08:19 +07:00
thuanleandClaude Opus 4.7 5e7a479991 fix: protect task_uuid from DispatchContext overwrite and update acceptance criteria
CI / fmt (pull_request) Successful in 4m44s
CI / test (pull_request) Successful in 12m48s
- Skip task_uuid key when merging DispatchContext into dispatch metadata
- Update requirements.md acceptance criteria and test matrix for external rules

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 07:01:20 +07:00
thuanleandClaude Opus 4.7 562d3b01c3 fix: reorder ingress checks and update requirements.md
CI / fmt (pull_request) Successful in 4m43s
CI / test (pull_request) Failing after 21m37s
- Move idempotency before external rules per ADR-0002
- Update requirements.md: new pipeline flow, BLOCKLIST_EMAILS,
  dispatch_context metadata

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 06:53:05 +07:00
thuanleandClaude Opus 4.7 92121278bb docs: add ADR-0002, update flow and add BLOCKLIST_EMAILS
CI / fmt (pull_request) Successful in 4m47s
CI / test (pull_request) Successful in 12m54s
- ADR-0002: external rules pipeline architecture
- README: updated flow to show safety checks → rules → dispatch
- .env.example: added BLOCKLIST_EMAILS

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 06:39:50 +07:00
thuanleandClaude Opus 4.7 aa05e3f7c0 feat: wire external rules pipeline into ingress and dispatch
- Add DispatchContext field to Task model for rule metadata storage
- Add BlocklistEmails config (BLOCKLIST_EMAILS env var)
- Wire rules.Pipeline into IMAPWatcher, replacing inline whitelist check
- Pass rule metadata through DispatchContext to OpenClaw dispatch
- Remove isWhitelisted method (now handled by WhitelistRule)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 06:38:37 +07:00
thuanleandClaude Opus 4.7 ab992abc26 feat(rules): add BlocklistRule
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 06:26:56 +07:00
thuanleandClaude Opus 4.7 8006e6d64c feat(rules): add Rule interface, Pipeline, and WhitelistRule
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 06:26:01 +07:00
thuanle 2233fd232a update workflow - auto assign yourself as assignee/reviewer 2026-04-28 06:23:28 +07:00
thuanle 3985b9dae3 Merge pull request 'Require PR discussion comments in reviewer workflow' (#17) from fix/reviewer-pr-discussion-comments into main
Reviewed-on: #17
2026-04-28 06:13:27 +07:00
thuanle 28224ccb9f docs: require PR discussion comments in reviews
CI / fmt (pull_request) Successful in 4m46s
CI / test (pull_request) Successful in 10m45s
2026-04-28 06:10:55 +07:00
thuanle 668b64d594 Merge pull request 'Add ADR workflow, update skills, rename do-task to implement-task' (#15) from feat/adr-workflow into main
Reviewed-on: #15
2026-04-28 05:56:21 +07:00
thuanleandClaude Opus 4.7 16db5acb3a fix: update validate-issue handoff to use implement-task
CI / fmt (pull_request) Successful in 4m55s
CI / test (pull_request) Successful in 10m58s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 05:55:18 +07:00
thuanleandClaude Opus 4.7 b013c68988 docs: add ADR workflow and update skills
CI / fmt (pull_request) Successful in 4m56s
CI / test (pull_request) Successful in 10m49s
- Add docs/adr/README.md with ADR format, rules, and index
- Add ADR-0001: IMAP thread resolution via In-Reply-To + References
- Add Documentation Rules section to AGENT.md
- Update validate-issue skill: flag ADR-requiring changes
- Rename do-task → implement-task for clearer semantics
- Update review-pr skill: accept docs/adr/, reject agent artifacts

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 05:47:37 +07:00
claudecode cb2d158689 Merge pull request 'Add References header fallback for thread resolution' (#13) from feat/references-threading into main 2026-04-28 05:39:02 +07:00
thuanleandClaude Opus 4.7 1eb9147ba1 fix: remove spec/plan artifacts from PR branch
CI / fmt (pull_request) Successful in 4m48s
CI / test (pull_request) Successful in 11m0s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 05:35:03 +07:00
thuanleandClaude Opus 4.7 4145f7a50b fix: remove out-of-scope plan files and gofmt test file
CI / fmt (pull_request) Successful in 5m54s
CI / test (pull_request) Successful in 13m1s
Address review feedback on PR #13:
- Remove docs/superpowers/plans/ files not related to issue #4
- gofmt internal/mail/resolve_thread_test.go

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 05:18:18 +07:00
thuanleandClaude Opus 4.7 c55a27d710 feat(mail): wire resolveThreadID into processMessage
CI / fmt (pull_request) Failing after 4m52s
CI / test (pull_request) Successful in 10m50s
- Add references variable to extract References header
- Replace manual inReplyTo handling with resolveThreadID call
- Maintain existing body parsing functionality
- Includes proper lambda function for database lookups

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 05:07:40 +07:00
thuanleandClaude Opus 4.7 15e697ddf3 fix(mail): remove redundant TrimSpace in parseReferences
strings.Fields already strips whitespace from each token.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 05:04:45 +07:00
thuanleandClaude Opus 4.7 c63895f4da feat(mail): implement resolveThreadID with References fallback
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 05:02:11 +07:00
thuanleandClaude Opus 4.7 aebae1cea5 test(mail): add resolveThreadID tests (RED)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 05:01:17 +07:00
thuanleandClaude Opus 4.7 270b13834a docs: add References threading implementation plan
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 04:57:00 +07:00
thuanleandClaude Opus 4.7 4090a44524 docs: add References header threading design spec
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 04:51:38 +07:00
claudecode 540dbdc0e0 Merge pull request 'Fix graceful HTTP server shutdown on SIGINT/SIGTERM' (#12) from fix/graceful-http-shutdown into main 2026-04-28 00:30:29 +07:00
thuanleandClaude Opus 4.7 31b31c7635 fix: graceful HTTP server shutdown on SIGINT/SIGTERM
CI / fmt (pull_request) Successful in 4m53s
CI / test (pull_request) Successful in 11m40s
Replace router.Run() with explicit http.Server + Shutdown() so the
process exits cleanly when context is cancelled.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 00:25:47 +07:00
thuanleandClaude Opus 4.7 1290e3b222 docs: require Fixes #<issue> in PR body for auto-close
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 00:22:43 +07:00
thuanleandClaude Opus 4.7 248a5bdbcd docs: add AGENT.md workflow and local skills
- AGENT.md: role-based issue-driven workflow (PO → Dev → Reviewer)
- CLAUDE.md: points to AGENT.md
- .codex/skills/: validate-issue, do-task, review-pr with phase gates

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 00:00:41 +07:00
thuanleandClaude Opus 4.7 8be84cc396 docs: consolidate project instructions into AGENT.md
Move behavioral guidelines, role-based workflow, and Gitea tooling
instructions into AGENT.md. CLAUDE.md now references AGENT.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 23:26:39 +07:00
26 changed files with 1210 additions and 110 deletions
+84
View File
@@ -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.
+118
View File
@@ -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.
+66
View File
@@ -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.
+1
View File
@@ -18,6 +18,7 @@ OPENCLAW_API_KEY=
BRIDGE_CALLBACK_TOKEN=a-strong-random-token BRIDGE_CALLBACK_TOKEN=a-strong-random-token
SYSTEM_EMAIL=bridge@example.com SYSTEM_EMAIL=bridge@example.com
WHITELIST_EMAILS=user1@example.com,user2@example.com WHITELIST_EMAILS=user1@example.com,user2@example.com
BLOCKLIST_EMAILS=
# === Optional === # === Optional ===
IMAP_PROXY_URL= IMAP_PROXY_URL=
+135
View File
@@ -0,0 +1,135 @@
# 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
- If git remote contains `git.thuanle.me`, ALWAYS use Gitea MCP tools.
- Scope: read PR/issue, list comments, post replies, create/edit PRs, reviews.
- Use `tea` CLI only as fallback when MCP is unavailable.
- Do not use `gh` for Gitea repositories.
- 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.
+2 -67
View File
@@ -1,70 +1,5 @@
# CLAUDE.md # 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. Agent skills are stored in `.codex/skills`
## 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.
+2 -1
View File
@@ -3,7 +3,7 @@
Tự động xử lý email bằng OpenClaw AI theo luồng: 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 ## 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` - **Threading** — giữ nguyên email thread qua `In-Reply-To` / `References`
- **Idempotency** — chống duplicate theo `message_id` - **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 - **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 - **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 - **Stateful** — SQLite lưu trạng thái pipeline, không mất dữ liệu khi restart
+17 -1
View File
@@ -3,10 +3,12 @@ package main
import ( import (
"context" "context"
"log/slog" "log/slog"
"net/http"
"os" "os"
"os/signal" "os/signal"
"sync" "sync"
"syscall" "syscall"
"time"
"thuanle.me/claw-email-bridge/internal/ai_client" "thuanle.me/claw-email-bridge/internal/ai_client"
"thuanle.me/claw-email-bridge/internal/api" "thuanle.me/claw-email-bridge/internal/api"
@@ -62,16 +64,30 @@ func main() {
// Start HTTP server with SMTP egress on callback. // Start HTTP server with SMTP egress on callback.
smtpSender := mail.NewSMTPSender(cfg, db) smtpSender := mail.NewSMTPSender(cfg, db)
router := api.NewRouter(db, cfg.BridgeCallbackToken, smtpSender.SendReplyFunc()) router := api.NewRouter(db, cfg.BridgeCallbackToken, smtpSender.SendReplyFunc())
srv := &http.Server{Addr: cfg.ListenAddr, Handler: router}
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
slog.Info("starting HTTP server", "addr", cfg.ListenAddr) 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) slog.Error("server exited with error", "error", err)
cancel() 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() wg.Wait()
slog.Info("bridge stopped") slog.Info("bridge stopped")
} }
+35
View File
@@ -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.
+30
View File
@@ -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).
+76
View File
@@ -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 |
@@ -0,0 +1,76 @@
# Design: Issue #18 — Add `go vet` and `staticcheck` to CI
## Context
Issue #18 requires expanding PR CI validation for this Go repository beyond existing `gofmt` and `go test` checks.
Current state:
- CI is defined in `.gitea/workflows/ci.yml`.
- Existing jobs: `fmt` (`gofmt -l .`) and `test` (`go test ./...`).
- No `go vet` or `staticcheck` step exists.
## Goal
Add practical baseline static analysis to pull request CI by:
- Running `go vet ./...`.
- Running `staticcheck ./...`.
- Pinning `staticcheck` version in CI for deterministic behavior.
## Non-goals
- Adding `golangci-lint`.
- Refactoring application code unrelated to CI wiring.
- Changing workflow triggers.
## Approved approach
Use separate CI jobs for `vet` and `staticcheck` while keeping existing `fmt` and `test` jobs unchanged.
### Why this approach
- Keeps changes surgical and easy to review.
- Makes failures explicit per check (better debugging signal than a combined lint step).
- Preserves current CI behavior while extending validation coverage.
## Implementation design
Modify `.gitea/workflows/ci.yml` only.
### Job: `vet`
- `runs-on: linux`
- Steps:
1. `actions/checkout@v4`
2. `actions/setup-go@v5` with `go-version-file: go.mod`
3. `run: go vet ./...`
### Job: `staticcheck`
- `runs-on: linux`
- Steps:
1. `actions/checkout@v4`
2. `actions/setup-go@v5` with `go-version-file: go.mod`
3. Install pinned staticcheck version:
- `go install honnef.co/go/tools/cmd/staticcheck@2025.1.1`
4. Run staticcheck:
- `$(go env GOPATH)/bin/staticcheck ./...`
## Failure behavior
- CI must fail if `go vet ./...` exits non-zero.
- CI must fail if `staticcheck ./...` exits non-zero.
- Existing `fmt` and `test` failure behavior remains unchanged.
## Verification plan
Local verification before PR creation:
- `gofmt -l .`
- `go test ./...`
- `go vet ./...`
- `staticcheck ./...` (if tool is available locally)
CI verification through PR run:
- Confirm `fmt`, `test`, `vet`, and `staticcheck` jobs execute on pull requests.
- Confirm non-zero from `vet`/`staticcheck` marks workflow failed.
## Risks and mitigations
- Risk: pinned staticcheck version not present in runner cache.
- Mitigation: install in-job via `go install` each run.
- Risk: staticcheck findings on current code block CI.
- Mitigation: if encountered, address only findings required to satisfy issue scope.
## Acceptance mapping
- CI fail on vet errors → `vet` job runs `go vet ./...`.
- CI fail on staticcheck errors → `staticcheck` job runs pinned staticcheck binary.
- Existing checks preserved → `fmt` and `test` jobs remain in workflow.
- No `golangci-lint` → not introduced in workflow.
+23 -1
View File
@@ -73,7 +73,7 @@ func (d *Dispatcher) Dispatch(task *database.Task) {
SessionID: task.ThreadID, SessionID: task.ThreadID,
History: history, History: history,
CallbackURL: callbackURL, CallbackURL: callbackURL,
Metadata: map[string]string{"task_uuid": task.TaskUUID}, Metadata: buildDispatchMetadata(task),
} }
// Update status to AI_PROCESSING before first attempt. // Update status to AI_PROCESSING before first attempt.
@@ -195,3 +195,25 @@ func (d *Dispatcher) buildHistory(threadID string) ([]historyEntry, error) {
return history, nil 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
}
+47
View File
@@ -12,6 +12,53 @@ import (
"thuanle.me/claw-email-bridge/internal/database" "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. // 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) { func TestDispatch_RetryThenSuccess(t *testing.T) {
tdb := database.NewTestDB(t) tdb := database.NewTestDB(t)
+9
View File
@@ -31,6 +31,7 @@ type Config struct {
BridgeCallbackToken string BridgeCallbackToken string
SystemEmail string SystemEmail string
WhitelistEmails []string WhitelistEmails []string
BlocklistEmails []string
// Optional // Optional
IMAPProxyURL string 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 == "" { if cfg.ListenAddr == "" {
cfg.ListenAddr = ":8080" cfg.ListenAddr = ":8080"
} }
+1
View File
@@ -24,6 +24,7 @@ type Task struct {
Sender string Sender string
Subject string Subject string
BodyPlain string BodyPlain string
DispatchContext string // JSON metadata from external rules pipeline.
AIResponse string AIResponse string
Status string `gorm:"index;not null"` Status string `gorm:"index;not null"`
AttemptOpenClaw int `gorm:"default:0"` AttemptOpenClaw int `gorm:"default:0"`
+74 -28
View File
@@ -3,6 +3,7 @@ package mail
import ( import (
"context" "context"
"crypto/tls" "crypto/tls"
"encoding/json"
"fmt" "fmt"
"io" "io"
"log/slog" "log/slog"
@@ -21,6 +22,7 @@ import (
"thuanle.me/claw-email-bridge/internal/config" "thuanle.me/claw-email-bridge/internal/config"
"thuanle.me/claw-email-bridge/internal/database" "thuanle.me/claw-email-bridge/internal/database"
"thuanle.me/claw-email-bridge/internal/logging" "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. // IMAPWatcher monitors an IMAP mailbox via IDLE and processes new emails.
@@ -32,6 +34,8 @@ type IMAPWatcher struct {
dialIMAP func(addr string, options *imapclient.Options) (*imapclient.Client, error) dialIMAP func(addr string, options *imapclient.Options) (*imapclient.Client, error)
dialIMAPViaProxy func(addr, proxyURL 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. // onReceived is called after a task is saved as RECEIVED.
// This will be wired to the OpenClaw dispatch in a future step. // This will be wired to the OpenClaw dispatch in a future step.
OnReceived func(task *database.Task) OnReceived func(task *database.Task)
@@ -57,11 +61,20 @@ var socks5DialerFactory = proxy.SOCKS5
// NewIMAPWatcher creates a new IMAP watcher. // NewIMAPWatcher creates a new IMAP watcher.
func NewIMAPWatcher(cfg *config.Config, db *gorm.DB) *IMAPWatcher { 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{ return &IMAPWatcher{
cfg: cfg, cfg: cfg,
db: db, db: db,
dialIMAP: imapclient.DialTLS, dialIMAP: imapclient.DialTLS,
dialIMAPViaProxy: dialTLSViaSOCKS5, dialIMAPViaProxy: dialTLSViaSOCKS5,
rules: rules.NewPipeline(externalRules),
} }
} }
@@ -283,6 +296,39 @@ func (w *IMAPWatcher) fetchUnseen(c *imapclient.Client) error {
return nil 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. // processMessage handles a single fetched email message.
func (w *IMAPWatcher) processMessage(c *imapclient.Client, buf *imapclient.FetchMessageBuffer, bodySection *imap.FetchItemBodySection) { func (w *IMAPWatcher) processMessage(c *imapclient.Client, buf *imapclient.FetchMessageBuffer, bodySection *imap.FetchItemBodySection) {
env := buf.Envelope env := buf.Envelope
@@ -310,15 +356,7 @@ func (w *IMAPWatcher) processMessage(c *imapclient.Client, buf *imapclient.Fetch
return return
} }
// 2. Whitelist: skip if sender not in allowed list. // 2. Idempotency: skip if message_id already exists.
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) existing, err := database.FindByMessageID(w.db, messageID)
if err != nil { if err != nil {
log.Error("imap: db lookup failed", "error", err) log.Error("imap: db lookup failed", "error", err)
@@ -330,9 +368,23 @@ func (w *IMAPWatcher) processMessage(c *imapclient.Client, buf *imapclient.Fetch
return 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. // 4. Threading: determine thread_id.
threadID := messageID threadID := messageID
inReplyTo := "" inReplyTo := ""
references := ""
// Parse body to get In-Reply-To / References headers and plain text. // Parse body to get In-Reply-To / References headers and plain text.
bodyPlain := "" bodyPlain := ""
@@ -340,10 +392,12 @@ func (w *IMAPWatcher) processMessage(c *imapclient.Client, buf *imapclient.Fetch
if rawBody != nil { if rawBody != nil {
mr, err := gomessage.CreateReader(io.NopCloser(strings.NewReader(string(rawBody)))) mr, err := gomessage.CreateReader(io.NopCloser(strings.NewReader(string(rawBody))))
if err == nil { if err == nil {
// Extract In-Reply-To header.
if irt, err := mr.Header.Text("In-Reply-To"); err == nil && irt != "" { if irt, err := mr.Header.Text("In-Reply-To"); err == nil && irt != "" {
inReplyTo = strings.TrimSpace(irt) inReplyTo = strings.TrimSpace(irt)
} }
if refs, err := mr.Header.Text("References"); err == nil && refs != "" {
references = refs
}
// Read body parts for plain text. // Read body parts for plain text.
for { for {
@@ -361,12 +415,9 @@ func (w *IMAPWatcher) processMessage(c *imapclient.Client, buf *imapclient.Fetch
} }
} }
if inReplyTo != "" { threadID = resolveThreadID(messageID, inReplyTo, references, func(mid string) (*database.Task, error) {
parent, err := database.FindByMessageID(w.db, inReplyTo) return database.FindByMessageID(w.db, mid)
if err == nil && parent != nil { })
threadID = parent.ThreadID
}
}
// 5. Save task with status RECEIVED. // 5. Save task with status RECEIVED.
taskUUID := uuid.New().String() taskUUID := uuid.New().String()
@@ -377,6 +428,7 @@ func (w *IMAPWatcher) processMessage(c *imapclient.Client, buf *imapclient.Fetch
Sender: sender, Sender: sender,
Subject: subject, Subject: subject,
BodyPlain: bodyPlain, BodyPlain: bodyPlain,
DispatchContext: marshalDispatchContext(ruleResult.Metadata),
Status: database.StatusReceived, Status: database.StatusReceived,
} }
@@ -420,19 +472,13 @@ func (w *IMAPWatcher) saveIgnored(messageID, sender, subject, reason string) {
} }
} }
// isWhitelisted checks if the sender is in the allowed list. // marshalDispatchContext serializes rule metadata to JSON.
// Empty whitelist means all senders are allowed. func marshalDispatchContext(metadata map[string]string) string {
func (w *IMAPWatcher) isWhitelisted(sender string) bool { if len(metadata) == 0 {
if len(w.cfg.WhitelistEmails) == 0 { return ""
return true
} }
lower := strings.ToLower(sender) data, _ := json.Marshal(metadata)
for _, email := range w.cfg.WhitelistEmails { return string(data)
if strings.ToLower(email) == lower {
return true
}
}
return false
} }
// markSeen flags a message as \Seen in IMAP. // markSeen flags a message as \Seen in IMAP.
+86
View File
@@ -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 }
+26
View File
@@ -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()
}
+35
View File
@@ -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")
}
}
+30
View File
@@ -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
}
+106
View File
@@ -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() }
+41
View File
@@ -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
}
+42
View File
@@ -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")
}
}
+30
View File
@@ -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
View File
@@ -47,6 +47,7 @@ Bảng `tasks`:
- `status` TEXT - `status` TEXT
- `attempt_openclaw` INTEGER DEFAULT 0 - `attempt_openclaw` INTEGER DEFAULT 0
- `attempt_smtp` 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 - `last_error` TEXT NULL
- `next_retry_at` DATETIME NULL (chỉ cần nếu retry xử lý theo scheduler/worker) - `next_retry_at` DATETIME NULL (chỉ cần nếu retry xử lý theo scheduler/worker)
- `created_at` DATETIME - `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. - 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: - Khi có email mới:
1. Anti-loop: nếu `sender` trùng email hệ thống -> `IGNORED`. 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`. 2. Idempotency: nếu `message_id` đã tồn tại -> bỏ qua.
3. 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: 4. Threading:
- Nếu có `In-Reply-To`/`References`: tìm email cha trong DB, kế thừa `thread_id`. - 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`. - 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) 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`). - 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) - `history` (optional)
- `callback_url` - `callback_url`
- `metadata.task_uuid` - `metadata.task_uuid`
- `metadata.*` từ `dispatch_context` (rule-derived context)
- Cập nhật `status = AI_PROCESSING`. - Cập nhật `status = AI_PROCESSING`.
Retry OpenClaw: Retry OpenClaw:
@@ -146,6 +148,7 @@ Required:
- `OPENCLAW_URL`, `OPENCLAW_API_KEY` (nếu dùng) - `OPENCLAW_URL`, `OPENCLAW_API_KEY` (nếu dùng)
- `BRIDGE_CALLBACK_TOKEN` - `BRIDGE_CALLBACK_TOKEN`
- `WHITELIST_EMAILS` (comma-separated) - `WHITELIST_EMAILS` (comma-separated)
- `BLOCKLIST_EMAILS` (comma-separated, optional)
- `SYSTEM_EMAIL` - `SYSTEM_EMAIL`
- `IMAP_PROXY_URL` (optional) - `IMAP_PROXY_URL` (optional)
@@ -166,7 +169,7 @@ Ví dụ:
3. OpenClaw/SMTP retry đúng thứ tự 1s -> 5s -> 15s. 3. OpenClaw/SMTP retry đúng thứ tự 1s -> 5s -> 15s.
4. Hết retry thì `FAILED` và có `last_error`. 4. Hết retry thì `FAILED` và có `last_error`.
5. Callback sai token trả 401 và không đổi state task. 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 10) Test matrix tối thiểu
@@ -175,4 +178,7 @@ Ví dụ:
3. SMTP fail hết retry -> `FAILED`, có `last_error`. 3. SMTP fail hết retry -> `FAILED`, có `last_error`.
4. Callback sai token -> 401, state không đổi. 4. Callback sai token -> 401, state không đổi.
5. Duplicate `message_id` -> không tạo task mớ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.