Add body read timeout recovery to IMAP client

Introduce Client wrapper with socket deadline support
Add reconnection logic for body read timeouts
Implement test cases for underflow scenarios
Update orchestrator to handle reconnections
This commit is contained in:
2026-07-21 05:29:35 +07:00
parent c741cd19a0
commit 6bf3a6c4ca
6 changed files with 375 additions and 30 deletions
+39 -5
View File
@@ -18,6 +18,29 @@ type Endpoint struct {
func (e Endpoint) addr() string { return fmt.Sprintf("%s:%d", e.Host, e.Port) }
// Client wraps an imapclient.Client together with the raw network connection it
// runs over. The embedded *imapclient.Client provides the full IMAP API; the
// retained conn lets callers impose a read deadline on the socket for the
// duration of a body transfer.
//
// This defends against servers that announce a BODY[] literal LARGER than the
// bytes they actually send (a protocol violation observed on some webmail
// servers). go-imap reads a literal strictly by its announced size, so a short
// literal makes it block forever waiting for bytes that never arrive. A
// deadline around the body read turns that infinite hang into a timeout the
// copier can recover from.
type Client struct {
*imapclient.Client
conn net.Conn
}
// SetReadDeadline sets (or, with the zero time, clears) a deadline on the
// underlying socket. Used to bound a single body read; always cleared again
// once the read completes so it never affects idle periods.
func (c *Client) SetReadDeadline(t time.Time) error {
return c.conn.SetReadDeadline(t)
}
// dialTimeout bounds establishing the TCP connection.
const dialTimeout = 30 * time.Second
@@ -27,7 +50,7 @@ const dialTimeout = 30 * time.Second
// 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) {
func dialOnce(ctx context.Context, ep Endpoint) (*Client, error) {
d := &net.Dialer{Timeout: dialTimeout}
raw, err := d.DialContext(ctx, "tcp", ep.addr())
if err != nil {
@@ -42,16 +65,27 @@ func dialOnce(ctx context.Context, ep Endpoint) (*imapclient.Client, error) {
_ = raw.Close()
return nil, err
}
return waitGreeting(imapclient.New(tlsConn, nil))
c, err := waitGreeting(imapclient.New(tlsConn, nil))
if err != nil {
return nil, err
}
return &Client{Client: c, conn: tlsConn}, nil
case "starttls":
// Deadline goes on the raw TCP conn: it sits beneath the TLS layer that
// NewStartTLS negotiates, and a TCP read deadline still interrupts the
// TLS read above it.
opts := &imapclient.Options{TLSConfig: &tls.Config{ServerName: ep.Host}}
c, err := imapclient.NewStartTLS(raw, opts)
if err != nil {
return nil, err
}
return c, nil
return &Client{Client: c, conn: raw}, nil
case "plain":
return waitGreeting(imapclient.New(raw, nil))
c, err := waitGreeting(imapclient.New(raw, nil))
if err != nil {
return nil, err
}
return &Client{Client: c, conn: raw}, nil
default:
_ = raw.Close()
return nil, fmt.Errorf("unknown tls_mode %q", ep.TLSMode)
@@ -69,7 +103,7 @@ func waitGreeting(c *imapclient.Client) (*imapclient.Client, error) {
return c, nil
}
func Connect(ctx context.Context, ep Endpoint) (*imapclient.Client, error) {
func Connect(ctx context.Context, ep Endpoint) (*Client, error) {
const attempts = 3
var lastErr error
for i := 0; i < attempts; i++ {