package imapx import ( "context" "crypto/tls" "fmt" "net" "time" "github.com/emersion/go-imap/v2/imapclient" ) type Endpoint struct { Host string Port int TLSMode string // ssl | starttls | plain } func (e Endpoint) addr() string { return fmt.Sprintf("%s:%d", e.Host, e.Port) } // dialTimeout bounds establishing the TCP connection. const dialTimeout = 30 * time.Second // dialOnce establishes one connection and returns a ready *Client. ctx bounds // the TCP dial. We deliberately do NOT impose a socket-level read deadline: // a blanket read deadline can't tell an idle connection (e.g. dst sitting idle // during a long src scan) from one stuck mid-response, and would wrongly close // idle connections. Stall detection is done at the orchestrator level via a // progress watchdog; go-imap's own per-command timeouts bound active commands. func dialOnce(ctx context.Context, ep Endpoint) (*imapclient.Client, error) { d := &net.Dialer{Timeout: dialTimeout} raw, err := d.DialContext(ctx, "tcp", ep.addr()) if err != nil { return nil, err } switch ep.TLSMode { case "ssl": // NextProtos mirrors imapclient.DialTLS's ALPN advertisement. tlsConn := tls.Client(raw, &tls.Config{ServerName: ep.Host, NextProtos: []string{"imap"}}) if err := tlsConn.HandshakeContext(ctx); err != nil { _ = raw.Close() return nil, err } return waitGreeting(imapclient.New(tlsConn, nil)) case "starttls": opts := &imapclient.Options{TLSConfig: &tls.Config{ServerName: ep.Host}} c, err := imapclient.NewStartTLS(raw, opts) if err != nil { return nil, err } return c, nil case "plain": return waitGreeting(imapclient.New(raw, nil)) default: _ = raw.Close() return nil, fmt.Errorf("unknown tls_mode %q", ep.TLSMode) } } // waitGreeting blocks for the server's initial greeting so a mute server is // caught at connect time rather than at the first command. NewStartTLS already // awaits the greeting during its STARTTLS upgrade. func waitGreeting(c *imapclient.Client) (*imapclient.Client, error) { if err := c.WaitGreeting(); err != nil { _ = c.Close() return nil, err } return c, nil } func Connect(ctx context.Context, ep Endpoint) (*imapclient.Client, error) { const attempts = 3 var lastErr error for i := 0; i < attempts; i++ { if err := ctx.Err(); err != nil { return nil, err } c, err := dialOnce(ctx, ep) if err == nil { return c, nil } lastErr = err if i < attempts-1 { backoff := time.Duration(200*(i+1)) * time.Millisecond select { case <-ctx.Done(): return nil, ctx.Err() case <-time.After(backoff): } } } return nil, lastErr } func TestEndpoint(ctx context.Context, ep Endpoint) error { c, err := Connect(ctx, ep) if err != nil { return err } return c.Logout().Wait() }