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
This commit is contained in:
2026-07-04 19:37:36 +07:00
parent 8656f46674
commit dca1363ec9
3 changed files with 136 additions and 9 deletions
+46 -9
View File
@@ -4,6 +4,7 @@ import (
"context"
"crypto/tls"
"fmt"
"net"
"time"
"github.com/emersion/go-imap/v2/imapclient"
@@ -17,23 +18,59 @@ type Endpoint struct {
func (e Endpoint) addr() string { return fmt.Sprintf("%s:%d", e.Host, e.Port) }
func dialOnce(ep Endpoint) (*imapclient.Client, error) {
// 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":
return imapclient.DialTLS(ep.addr(), &imapclient.Options{
TLSConfig: &tls.Config{ServerName: ep.Host},
})
// 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":
return imapclient.DialStartTLS(ep.addr(), &imapclient.Options{
TLSConfig: &tls.Config{ServerName: ep.Host},
})
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":
return imapclient.DialInsecure(ep.addr(), nil)
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
@@ -41,7 +78,7 @@ func Connect(ctx context.Context, ep Endpoint) (*imapclient.Client, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
c, err := dialOnce(ep)
c, err := dialOnce(ctx, ep)
if err == nil {
return c, nil
}