From 14f2bd7eabc4aa4eda7e8c138263deadf9775c63 Mon Sep 17 00:00:00 2001 From: thuanle Date: Mon, 27 Apr 2026 21:37:40 +0700 Subject: [PATCH] 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) + } +}