diff --git a/docs/superpowers/plans/2026-04-28-references-threading.md b/docs/superpowers/plans/2026-04-28-references-threading.md new file mode 100644 index 0000000..d3823d6 --- /dev/null +++ b/docs/superpowers/plans/2026-04-28-references-threading.md @@ -0,0 +1,261 @@ +# References Header Threading Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `References` header fallback so threading resolves correctly when `In-Reply-To` is absent. + +**Architecture:** Extract a `resolveThreadID` helper from `processMessage` that accepts a DB lookup function and parsed headers. This makes the threading logic testable without needing IMAP client infrastructure. The helper tries `In-Reply-To` first, then iterates `References` last-to-first on miss. + +**Tech Stack:** Go, `github.com/emersion/go-message/mail`, `gorm.io/gorm`. + +--- + +### Task 1: Write failing tests for resolveThreadID + +**Files:** +- Create: `internal/mail/resolve_thread_test.go` +- Modify: `internal/mail/imap.go` (add `resolveThreadID` stub) + +- [ ] **Step 1: Write failing tests** + +Create `internal/mail/resolve_thread_test.go`: + +```go +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 == "" { + return &database.Task{ThreadID: "thread-abc"}, nil + } + return nil, nil + } + got := resolveThreadID("msg-2", "", "", 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 == "" { + return &database.Task{ThreadID: "thread-c"}, nil + } + return nil, nil + } + got := resolveThreadID("msg-3", "", " ", 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 == "" { + return &database.Task{ThreadID: "thread-b"}, nil + } + return nil, nil + } + got := resolveThreadID("msg-4", "", " ", 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", "", " ", 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 == "" { + return &database.Task{ThreadID: "thread-ref"}, nil + } + return nil, nil + } + got := resolveThreadID("msg-6", "", "", 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", "", "", lookup) + if got != "msg-7" { + t.Fatalf("expected msg-7, got %s", got) + } +} + +var nilLookup = func(string) (*database.Task, error) { return nil, nil } +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/mail -run ResolveThreadID -v` +Expected: FAIL — `resolveThreadID` not defined. + +- [ ] **Step 3: Commit test file** + +```bash +git add internal/mail/resolve_thread_test.go +git commit -m "test(mail): add resolveThreadID tests (RED)" +``` + +### Task 2: Implement resolveThreadID + +**Files:** +- Modify: `internal/mail/imap.go` + +- [ ] **Step 1: Add resolveThreadID function** + +Add to `internal/mail/imap.go`, before the `processMessage` function: + +```go +// 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) + for i := range raw { + raw[i] = strings.TrimSpace(raw[i]) + } + // 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 +} +``` + +- [ ] **Step 2: Run tests to verify they pass** + +Run: `go test ./internal/mail -run ResolveThreadID -v` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add internal/mail/imap.go +git commit -m "feat(mail): implement resolveThreadID with References fallback" +``` + +### Task 3: Wire resolveThreadID into processMessage + +**Files:** +- Modify: `internal/mail/imap.go` + +- [ ] **Step 1: Replace threading block in processMessage** + +Replace the block at `imap.go:333-369` (the threading section) with: + +```go + // 4. Threading: determine thread_id. + threadID := messageID + inReplyTo := "" + references := "" + + // Parse body to get In-Reply-To / References headers and plain text. + bodyPlain := "" + rawBody := buf.FindBodySection(bodySection) + if rawBody != nil { + mr, err := gomessage.CreateReader(io.NopCloser(strings.NewReader(string(rawBody)))) + if err == nil { + 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 + } + + for { + p, err := mr.NextPart() + if err != nil { + break + } + if _, ok := p.Header.(*gomessage.InlineHeader); ok { + b, _ := io.ReadAll(p.Body) + if bodyPlain == "" { + bodyPlain = string(b) + } + } + } + } + } + + threadID = resolveThreadID(messageID, inReplyTo, references, func(mid string) (*database.Task, error) { + return database.FindByMessageID(w.db, mid) + }) +``` + +- [ ] **Step 2: Run full test suite** + +Run: `go test ./internal/mail -v` +Expected: PASS (all ResolveThreadID + existing dial tests). + +- [ ] **Step 3: Run full project tests** + +Run: `go test ./...` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add internal/mail/imap.go +git commit -m "feat(mail): wire resolveThreadID into processMessage" +``` + +### Task 4: Final verification + +- [ ] **Step 1: Run full validation** + +Run: `go build ./... && go vet ./... && go test ./... -v` +Expected: all PASS. + +- [ ] **Step 2: Push and create PR** + +```bash +git push -u origin +``` + +PR body must include `Fixes #4`.