From 2333ec3f00b225115773b60a7085fb1632568bcb Mon Sep 17 00:00:00 2001 From: thuanle Date: Mon, 27 Apr 2026 21:34:00 +0700 Subject: [PATCH 1/4] chore: ignore local worktrees directory Add .worktrees/ to .gitignore to prevent accidental tracking of isolated worktree files. Co-Authored-By: Claude Opus 4.7 --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 31ae8a5..1c6d418 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ data/*.db-shm .idea/ .vscode/ *.swp + +# Local worktrees +.worktrees/ From 14f2bd7eabc4aa4eda7e8c138263deadf9775c63 Mon Sep 17 00:00:00 2001 From: thuanle Date: Mon, 27 Apr 2026 21:37:40 +0700 Subject: [PATCH 2/4] feat(mail): support IMAP SOCKS5 proxy dialing Use IMAP_PROXY_URL to route IMAP TLS connections through SOCKS5 when configured, while preserving direct dialing when unset. Co-Authored-By: Claude Opus 4.7 --- internal/mail/imap.go | 62 ++++++++++++++++++++++++++++-- internal/mail/imap_test.go | 77 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 3 deletions(-) create mode 100644 internal/mail/imap_test.go diff --git a/internal/mail/imap.go b/internal/mail/imap.go index c3e7dd5..0a48ca8 100644 --- a/internal/mail/imap.go +++ b/internal/mail/imap.go @@ -2,9 +2,12 @@ package mail import ( "context" + "crypto/tls" "fmt" "io" "log/slog" + "net" + "net/url" "strings" "time" @@ -13,6 +16,7 @@ import ( "github.com/emersion/go-imap/v2" "github.com/emersion/go-imap/v2/imapclient" "github.com/google/uuid" + "golang.org/x/net/proxy" "thuanle.me/claw-email-bridge/internal/config" "thuanle.me/claw-email-bridge/internal/database" "thuanle.me/claw-email-bridge/internal/logging" @@ -25,6 +29,9 @@ type IMAPWatcher struct { 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 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) @@ -32,7 +39,12 @@ type IMAPWatcher struct { // NewIMAPWatcher creates a new IMAP watcher. func NewIMAPWatcher(cfg *config.Config, db *gorm.DB) *IMAPWatcher { - return &IMAPWatcher{cfg: cfg, db: db} + return &IMAPWatcher{ + cfg: cfg, + db: db, + dialIMAP: imapclient.DialTLS, + dialIMAPViaProxy: dialTLSViaSOCKS5, + } } // Run connects to IMAP, selects INBOX, and enters the IDLE loop. @@ -71,9 +83,15 @@ func (w *IMAPWatcher) connectAndWatch(ctx context.Context) error { addr := fmt.Sprintf("%s:%s", w.cfg.IMAPHost, w.cfg.IMAPPort) slog.Info("imap: connecting", "addr", addr) - c, err := imapclient.DialTLS(addr, options) + var c *imapclient.Client + var err error + 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 TLS: %w", err) + return fmt.Errorf("dial IMAP: %w", err) } w.client = c defer func() { @@ -144,6 +162,44 @@ func (w *IMAPWatcher) connectAndWatch(ctx context.Context) error { } } +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} + } + + dialer, err := proxy.SOCKS5("tcp", u.Host, auth, proxy.Direct) + if err != nil { + return nil, fmt.Errorf("create socks5 dialer: %w", err) + } + + conn, err := dialer.Dial("tcp", addr) + if err != nil { + return nil, fmt.Errorf("proxy dial: %w", err) + } + + host, _, splitErr := net.SplitHostPort(addr) + if splitErr != nil || host == "" { + host = 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 +} + // fetchUnseen searches for UNSEEN messages and processes each one. func (w *IMAPWatcher) fetchUnseen(c *imapclient.Client) error { criteria := &imap.SearchCriteria{ diff --git a/internal/mail/imap_test.go b/internal/mail/imap_test.go new file mode 100644 index 0000000..0e9cecb --- /dev/null +++ b/internal/mail/imap_test.go @@ -0,0 +1,77 @@ +package mail + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/emersion/go-imap/v2/imapclient" + "thuanle.me/claw-email-bridge/internal/config" +) + +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.dialIMAPViaProxy = func(addr, proxyURL string, _ *imapclient.Options) (*imapclient.Client, error) { + t.Fatalf("did not expect proxy dial, got addr=%s proxy=%s", addr, proxyURL) + return nil, nil + } + + err := watcher.connectAndWatch(context.Background()) + if err == nil || !strings.Contains(err.Error(), "stop") { + t.Fatalf("expected stop error, got %v", err) + } + if !directCalled { + t.Fatal("expected direct dial path") + } +} + +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.dialIMAP = func(addr string, _ *imapclient.Options) (*imapclient.Client, error) { + t.Fatalf("did not expect direct dial, got addr=%s", addr) + return nil, nil + } + watcher.dialIMAPViaProxy = func(addr, proxyURL string, _ *imapclient.Options) (*imapclient.Client, error) { + proxyCalled = true + return nil, errors.New("stop") + } + + err := watcher.connectAndWatch(context.Background()) + if err == nil || !strings.Contains(err.Error(), "stop") { + t.Fatalf("expected stop error, got %v", err) + } + if !proxyCalled { + t.Fatal("expected proxy dial path") + } +} + +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) + } +} From d0c22a0554bbdebf4461dbb46b58e5002c5a6e58 Mon Sep 17 00:00:00 2001 From: thuanle Date: Mon, 27 Apr 2026 21:59:25 +0700 Subject: [PATCH 3/4] fix(mail): restore IMAP SOCKS5 dial timeout behavior Preserve the 30s dial timeout semantics for proxy connections by using a timeout-backed forward dialer, and add a regression test to lock this behavior. Co-Authored-By: Claude Opus 4.7 --- internal/mail/imap.go | 14 +++++++++++++- internal/mail/imap_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/internal/mail/imap.go b/internal/mail/imap.go index 0a48ca8..c59da47 100644 --- a/internal/mail/imap.go +++ b/internal/mail/imap.go @@ -37,6 +37,18 @@ type IMAPWatcher struct { OnReceived func(task *database.Task) } +const imapDialTimeout = 30 * time.Second + +type timeoutDialer struct { + timeout time.Duration +} + +func (d timeoutDialer) Dial(network, address string) (net.Conn, error) { + return (&net.Dialer{Timeout: d.timeout}).Dial(network, address) +} + +var socks5DialerFactory = proxy.SOCKS5 + // NewIMAPWatcher creates a new IMAP watcher. func NewIMAPWatcher(cfg *config.Config, db *gorm.DB) *IMAPWatcher { return &IMAPWatcher{ @@ -177,7 +189,7 @@ func dialTLSViaSOCKS5(addr, proxyURL string, options *imapclient.Options) (*imap auth = &proxy.Auth{User: u.User.Username(), Password: pw} } - dialer, err := proxy.SOCKS5("tcp", u.Host, auth, proxy.Direct) + dialer, err := socks5DialerFactory("tcp", u.Host, auth, timeoutDialer{timeout: imapDialTimeout}) if err != nil { return nil, fmt.Errorf("create socks5 dialer: %w", err) } diff --git a/internal/mail/imap_test.go b/internal/mail/imap_test.go index 0e9cecb..70c2f97 100644 --- a/internal/mail/imap_test.go +++ b/internal/mail/imap_test.go @@ -3,10 +3,13 @@ package mail import ( "context" "errors" + "net" "strings" "testing" + "time" "github.com/emersion/go-imap/v2/imapclient" + "golang.org/x/net/proxy" "thuanle.me/claw-email-bridge/internal/config" ) @@ -75,3 +78,30 @@ func TestDialTLSViaSOCKS5_RejectsNonSocks5Scheme(t *testing.T) { t.Fatalf("expected unsupported scheme error, got %v", err) } } + +type errDialer struct{} + +func (errDialer) Dial(network, address string) (net.Conn, error) { + return nil, errors.New("stop") +} + +func TestDialTLSViaSOCKS5_UsesTimeoutForwardDialer(t *testing.T) { + orig := socks5DialerFactory + t.Cleanup(func() { socks5DialerFactory = orig }) + + socks5DialerFactory = func(network, address string, auth *proxy.Auth, forward proxy.Dialer) (proxy.Dialer, error) { + td, ok := forward.(timeoutDialer) + if !ok { + t.Fatalf("expected timeoutDialer, got %T", forward) + } + if td.timeout != 30*time.Second { + t.Fatalf("expected timeout 30s, got %s", td.timeout) + } + return errDialer{}, nil + } + + _, err := dialTLSViaSOCKS5("imap.example.com:993", "socks5://127.0.0.1:1080", nil) + if err == nil || !strings.Contains(err.Error(), "proxy dial: stop") { + t.Fatalf("expected proxy dial stop error, got %v", err) + } +} From d1c35e0b5724cfe43f41353dbbef679dbb333e71 Mon Sep 17 00:00:00 2001 From: thuanle Date: Mon, 27 Apr 2026 22:10:20 +0700 Subject: [PATCH 4/4] fix(mail): enforce end-to-end timeout for SOCKS5 IMAP dial Use context-based SOCKS dialing and bounded TLS handshake so proxy negotiation and handshake cannot hang indefinitely, with regression tests for both paths. Co-Authored-By: Claude Opus 4.7 --- internal/mail/imap.go | 30 +++++++++++--- internal/mail/imap_test.go | 81 ++++++++++++++++++++++++++++++++------ 2 files changed, 92 insertions(+), 19 deletions(-) diff --git a/internal/mail/imap.go b/internal/mail/imap.go index c59da47..8c9880d 100644 --- a/internal/mail/imap.go +++ b/internal/mail/imap.go @@ -39,12 +39,18 @@ type IMAPWatcher struct { const imapDialTimeout = 30 * time.Second -type timeoutDialer struct { +type timeoutContextDialer struct { timeout time.Duration } -func (d timeoutDialer) Dial(network, address string) (net.Conn, error) { - return (&net.Dialer{Timeout: d.timeout}).Dial(network, address) +func (d timeoutContextDialer) Dial(network, address string) (net.Conn, error) { + ctx, cancel := context.WithTimeout(context.Background(), d.timeout) + defer cancel() + return d.DialContext(ctx, network, address) +} + +func (d timeoutContextDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + return (&net.Dialer{Timeout: d.timeout}).DialContext(ctx, network, address) } var socks5DialerFactory = proxy.SOCKS5 @@ -189,12 +195,20 @@ func dialTLSViaSOCKS5(addr, proxyURL string, options *imapclient.Options) (*imap auth = &proxy.Auth{User: u.User.Username(), Password: pw} } - dialer, err := socks5DialerFactory("tcp", u.Host, auth, timeoutDialer{timeout: imapDialTimeout}) + dialer, err := socks5DialerFactory("tcp", u.Host, auth, timeoutContextDialer{timeout: imapDialTimeout}) if err != nil { return nil, fmt.Errorf("create socks5 dialer: %w", err) } - conn, err := dialer.Dial("tcp", addr) + 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) + } if err != nil { return nil, fmt.Errorf("proxy dial: %w", err) } @@ -204,10 +218,14 @@ func dialTLSViaSOCKS5(addr, proxyURL string, options *imapclient.Options) (*imap host = addr } tlsConn := tls.Client(conn, &tls.Config{ServerName: host}) - if err := tlsConn.Handshake(); err != nil { + _ = tlsConn.SetDeadline(time.Now().Add(imapDialTimeout)) + handshakeCtx, handshakeCancel := context.WithTimeout(context.Background(), imapDialTimeout) + defer handshakeCancel() + if err := tlsConn.HandshakeContext(handshakeCtx); err != nil { _ = tlsConn.Close() return nil, fmt.Errorf("tls handshake: %w", err) } + _ = tlsConn.SetDeadline(time.Time{}) return imapclient.New(tlsConn, options), nil } diff --git a/internal/mail/imap_test.go b/internal/mail/imap_test.go index 70c2f97..22a47ee 100644 --- a/internal/mail/imap_test.go +++ b/internal/mail/imap_test.go @@ -3,6 +3,7 @@ package mail import ( "context" "errors" + "io" "net" "strings" "testing" @@ -79,29 +80,83 @@ func TestDialTLSViaSOCKS5_RejectsNonSocks5Scheme(t *testing.T) { } } -type errDialer struct{} +type fakeContextDialer struct{} -func (errDialer) Dial(network, address string) (net.Conn, error) { - return nil, errors.New("stop") +func (fakeContextDialer) Dial(network, address string) (net.Conn, error) { + return nil, errors.New("used dial") } -func TestDialTLSViaSOCKS5_UsesTimeoutForwardDialer(t *testing.T) { +func (fakeContextDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + if _, ok := ctx.Deadline(); !ok { + return nil, errors.New("missing deadline") + } + return nil, errors.New("used dialcontext") +} + +func TestDialTLSViaSOCKS5_UsesDialContextWithTimeout(t *testing.T) { orig := socks5DialerFactory t.Cleanup(func() { socks5DialerFactory = orig }) socks5DialerFactory = func(network, address string, auth *proxy.Auth, forward proxy.Dialer) (proxy.Dialer, error) { - td, ok := forward.(timeoutDialer) - if !ok { - t.Fatalf("expected timeoutDialer, got %T", forward) + if _, ok := forward.(proxy.ContextDialer); !ok { + t.Fatalf("expected ContextDialer, got %T", forward) } - if td.timeout != 30*time.Second { - t.Fatalf("expected timeout 30s, got %s", td.timeout) - } - return errDialer{}, nil + return fakeContextDialer{}, nil } _, err := dialTLSViaSOCKS5("imap.example.com:993", "socks5://127.0.0.1:1080", nil) - if err == nil || !strings.Contains(err.Error(), "proxy dial: stop") { - t.Fatalf("expected proxy dial stop error, got %v", err) + if err == nil || !strings.Contains(err.Error(), "proxy dial: used dialcontext") { + t.Fatalf("expected DialContext path, got %v", err) + } +} + +type trackingConn struct { + deadline time.Time +} + +func (c *trackingConn) Read(b []byte) (int, error) { return 0, io.EOF } +func (c *trackingConn) Write(b []byte) (int, error) { return 0, io.EOF } +func (c *trackingConn) Close() error { return nil } +func (c *trackingConn) LocalAddr() net.Addr { return dummyAddr("local") } +func (c *trackingConn) RemoteAddr() net.Addr { return dummyAddr("remote") } +func (c *trackingConn) SetDeadline(t time.Time) error { + c.deadline = t + return nil +} +func (c *trackingConn) SetReadDeadline(t time.Time) error { return nil } +func (c *trackingConn) SetWriteDeadline(t time.Time) error { return nil } + +type dummyAddr string + +func (a dummyAddr) Network() string { return "tcp" } +func (a dummyAddr) String() string { return string(a) } + +type contextConnDialer struct { + conn *trackingConn +} + +func (d contextConnDialer) Dial(network, address string) (net.Conn, error) { + return nil, errors.New("used dial") +} + +func (d contextConnDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + return d.conn, nil +} + +func TestDialTLSViaSOCKS5_SetsDeadlineForTLSHandshake(t *testing.T) { + orig := socks5DialerFactory + t.Cleanup(func() { socks5DialerFactory = orig }) + + conn := &trackingConn{} + socks5DialerFactory = func(network, address string, auth *proxy.Auth, forward proxy.Dialer) (proxy.Dialer, error) { + return contextConnDialer{conn: conn}, nil + } + + _, err := dialTLSViaSOCKS5("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) + } + if conn.deadline.IsZero() { + t.Fatal("expected TLS handshake deadline to be set") } }