Files
imap-copier/internal/imapx/dial.go
T
vasyansk 6bf3a6c4ca 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
2026-07-21 05:29:35 +07:00

137 lines
4.1 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) }
// 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
// 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) (*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
}
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 &Client{Client: c, conn: raw}, nil
case "plain":
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)
}
}
// 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) (*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()
}