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
137 lines
4.1 KiB
Go
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()
|
|
}
|