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 (matches go-imap's own // default). The subsequent idleReadTimeout governs reads once connected. const dialTimeout = 30 * time.Second // dialOnce establishes one connection and returns a ready *Client whose reads // are guarded by idleReadTimeout. Unlike imapclient.Dial*, the underlying TCP // conn is wrapped in idleConn so a server that stops responding mid-command // unblocks the read instead of hanging forever. ctx bounds the TCP dial. 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 } conn := &idleConn{Conn: raw, timeout: idleReadTimeout} switch ep.TLSMode { case "ssl": // NextProtos mirrors imapclient.DialTLS's ALPN advertisement. tlsConn := tls.Client(conn, &tls.Config{ServerName: ep.Host, NextProtos: []string{"imap"}}) if err := tlsConn.HandshakeContext(ctx); err != nil { _ = conn.Close() return nil, err } c := imapclient.New(tlsConn, nil) return waitGreeting(c) case "starttls": opts := &imapclient.Options{TLSConfig: &tls.Config{ServerName: ep.Host}} c, err := imapclient.NewStartTLS(conn, opts) if err != nil { return nil, err } return c, nil case "plain": c := imapclient.New(conn, nil) return waitGreeting(c) default: _ = conn.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 (bounded by idleReadTimeout) 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() }