Files
imap-copier/internal/imapx/dial.go
T
vasyansk dca1363ec9 Add IMAP connection timeout handling
Introduce idleConn wrapper to prevent wedged connections
Add test for silent server timeout behavior
Implement proper TLS handshake and greeting handling
Set explicit dial and read timeouts
2026-07-04 19:37:36 +07:00

105 lines
2.8 KiB
Go

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()
}