# IMAP Proxy Support 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:** Make IMAP connections route through `IMAP_PROXY_URL` when configured, while keeping current direct dial behavior when unset. **Architecture:** Introduce a small dial abstraction in IMAP watcher so connection setup is testable and can branch between direct TLS dial and SOCKS5 proxy dial. Keep authentication/fetch logic unchanged; only connection creation path changes. Validate proxy URL format early and fail clearly when invalid. **Tech Stack:** Go, `github.com/emersion/go-imap/v2/imapclient`, `golang.org/x/net/proxy`, `crypto/tls`. --- ### Task 1: Add failing tests for dial-path behavior **Files:** - Modify: `internal/mail/imap_test.go` (create) - Modify: `internal/mail/imap.go` (for injectable dial function used by tests) - [ ] **Step 1: Write failing test for direct dial when proxy unset** ```go func TestConnectAndWatch_UsesDirectDial_WhenProxyUnset(t *testing.T) { watcher := &IMAPWatcher{cfg: &config.Config{IMAPHost: "imap.example.com", IMAPPort: "993", IMAPUser: "u", IMAPPass: "p"}} var directCalled bool watcher.dialIMAP = func(addr string, _ *imapclient.Options) (*imapclient.Client, error) { directCalled = true return nil, errors.New("stop") } _ = watcher.connectAndWatch(context.Background()) if !directCalled { t.Fatal("expected direct dial path") } } ``` - [ ] **Step 2: Write failing test for proxy dial when proxy set** ```go func TestConnectAndWatch_UsesProxyDial_WhenProxySet(t *testing.T) { watcher := &IMAPWatcher{cfg: &config.Config{IMAPHost: "imap.example.com", IMAPPort: "993", IMAPUser: "u", IMAPPass: "p", IMAPProxyURL: "socks5://127.0.0.1:1080"}} var proxyCalled bool watcher.dialIMAPViaProxy = func(addr, proxyURL string, _ *imapclient.Options) (*imapclient.Client, error) { proxyCalled = true return nil, errors.New("stop") } _ = watcher.connectAndWatch(context.Background()) if !proxyCalled { t.Fatal("expected proxy dial path") } } ``` - [ ] **Step 3: Run tests to verify RED** Run: `go test ./internal/mail -run Uses -v` Expected: FAIL because `dialIMAP` / `dialIMAPViaProxy` hooks do not exist yet. - [ ] **Step 4: Commit test scaffold after it compiles with new hooks (still failing behavior checks if needed)** ```bash git add internal/mail/imap_test.go internal/mail/imap.go git commit -m "test(mail): add dial path tests for IMAP proxy" ``` ### Task 2: Implement proxy-aware dialing with minimal changes **Files:** - Modify: `internal/mail/imap.go` - [ ] **Step 1: Add dial hooks to watcher for testability** ```go type IMAPWatcher struct { cfg *config.Config db *gorm.DB client *imapclient.Client dialIMAP func(addr string, options *imapclient.Options) (*imapclient.Client, error) dialIMAPViaProxy func(addr, proxyURL string, options *imapclient.Options) (*imapclient.Client, error) OnReceived func(task *database.Task) } ``` - [ ] **Step 2: Initialize default dial hooks in constructor** ```go func NewIMAPWatcher(cfg *config.Config, db *gorm.DB) *IMAPWatcher { return &IMAPWatcher{ cfg: cfg, db: db, dialIMAP: imapclient.DialTLS, dialIMAPViaProxy: dialTLSViaSOCKS5, } } ``` - [ ] **Step 3: Branch connection setup by `IMAPProxyURL`** ```go if strings.TrimSpace(w.cfg.IMAPProxyURL) == "" { c, err = w.dialIMAP(addr, options) } else { c, err = w.dialIMAPViaProxy(addr, w.cfg.IMAPProxyURL, options) } if err != nil { return fmt.Errorf("dial IMAP: %w", err) } ``` - [ ] **Step 4: Implement SOCKS5 TLS dial helper** ```go func dialTLSViaSOCKS5(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} } d, err := proxy.SOCKS5("tcp", u.Host, auth, proxy.Direct) if err != nil { return nil, fmt.Errorf("create socks5 dialer: %w", err) } conn, err := d.Dial("tcp", addr) if err != nil { return nil, fmt.Errorf("proxy dial: %w", err) } host, _, _ := net.SplitHostPort(addr) tlsConn := tls.Client(conn, &tls.Config{ServerName: host}) if err := tlsConn.Handshake(); err != nil { _ = tlsConn.Close() return nil, fmt.Errorf("tls handshake: %w", err) } return imapclient.New(tlsConn, options), nil } ``` - [ ] **Step 5: Run focused tests to verify GREEN** Run: `go test ./internal/mail -run Uses -v` Expected: PASS. - [ ] **Step 6: Commit implementation** ```bash git add internal/mail/imap.go internal/mail/imap_test.go git commit -m "feat(mail): support IMAP SOCKS5 proxy dialing" ``` ### Task 3: Add URL validation coverage and docs consistency **Files:** - Modify: `internal/mail/imap_test.go` - Modify: `README.md` (only if wording needs tighter scheme requirement) - [ ] **Step 1: Write test for invalid proxy scheme** ```go func TestDialTLSViaSOCKS5_RejectsNonSocks5Scheme(t *testing.T) { _, err := dialTLSViaSOCKS5("imap.example.com:993", "http://proxy:8080", nil) if err == nil || !strings.Contains(err.Error(), "unsupported proxy scheme") { t.Fatalf("expected unsupported scheme error, got %v", err) } } ``` - [ ] **Step 2: Run targeted test** Run: `go test ./internal/mail -run RejectsNonSocks5Scheme -v` Expected: PASS. - [ ] **Step 3: Ensure README explicitly says socks5 scheme (already present, keep if unchanged)** ```md IMAP_PROXY_URL: socks5://user:pass@host:port ``` - [ ] **Step 4: Commit doc/test alignment** ```bash git add internal/mail/imap_test.go README.md git commit -m "test/docs: validate IMAP proxy URL scheme" ``` ### Task 4: Final verification **Files:** - No new files expected - [ ] **Step 1: Run package tests for mail** Run: `go test ./internal/mail -v` Expected: PASS. - [ ] **Step 2: Run full suite** Run: `go test ./...` Expected: PASS. - [ ] **Step 3: Push and update issue/PR with evidence** ```bash git push -u origin ``` Include commands and pass results in issue/PR comment.