package pop3 import ( "bufio" "bytes" "crypto/tls" "fmt" "net" "strconv" "strings" "time" ) const dialTimeout = 30 * time.Second const rwTimeout = 30 * time.Second // Client is a minimal POP3 client implemented directly over net.Conn. // It supports SSL, STARTTLS, and plaintext connections. type Client struct { conn net.Conn reader *bufio.Reader } // Dial connects to a POP3 server and reads the server greeting. // tlsMode must be one of: "ssl", "starttls", "none". func Dial(host string, port int, tlsMode string, skipVerify bool) (*Client, error) { addr := net.JoinHostPort(host, strconv.Itoa(port)) tlsCfg := &tls.Config{ ServerName: host, InsecureSkipVerify: skipVerify, //nolint:gosec // user-controlled opt-in } var conn net.Conn var err error switch tlsMode { case "ssl": dialer := &tls.Dialer{ NetDialer: &net.Dialer{Timeout: dialTimeout}, Config: tlsCfg, } conn, err = dialer.Dial("tcp", addr) if err != nil { return nil, fmt.Errorf("pop3 dial ssl: %w", err) } case "starttls": plain, err2 := net.DialTimeout("tcp", addr, dialTimeout) if err2 != nil { return nil, fmt.Errorf("pop3 dial starttls plain: %w", err2) } c := &Client{conn: plain, reader: bufio.NewReader(plain)} // Read server greeting before STLS if _, err2 := c.readLine(); err2 != nil { plain.Close() return nil, fmt.Errorf("pop3 starttls greeting: %w", err2) } if err2 := c.sendCmd("STLS"); err2 != nil { plain.Close() return nil, fmt.Errorf("pop3 starttls send: %w", err2) } if _, err2 := c.readLine(); err2 != nil { plain.Close() return nil, fmt.Errorf("pop3 starttls response: %w", err2) } tlsConn := tls.Client(plain, tlsCfg) if err2 := tlsConn.Handshake(); err2 != nil { tlsConn.Close() return nil, fmt.Errorf("pop3 starttls handshake: %w", err2) } // Return upgraded client โ€” greeting already consumed return &Client{conn: tlsConn, reader: bufio.NewReader(tlsConn)}, nil default: // "none" conn, err = net.DialTimeout("tcp", addr, dialTimeout) if err != nil { return nil, fmt.Errorf("pop3 dial plain: %w", err) } } c := &Client{conn: conn, reader: bufio.NewReader(conn)} // Read and discard the server greeting (+OK ...) if _, err := c.readLine(); err != nil { conn.Close() return nil, fmt.Errorf("pop3 greeting: %w", err) } return c, nil } // Login authenticates with USER and PASS commands. func (c *Client) Login(user, pass string) error { if err := c.sendCmd("USER " + user); err != nil { return fmt.Errorf("pop3 login user: %w", err) } if _, err := c.readLine(); err != nil { return fmt.Errorf("pop3 login user response: %w", err) } if err := c.sendCmd("PASS " + pass); err != nil { return fmt.Errorf("pop3 login pass: %w", err) } if _, err := c.readLine(); err != nil { return fmt.Errorf("pop3 login pass response: %w", err) } return nil } // Stat returns the message count and total mailbox size in bytes. func (c *Client) Stat() (count, size int, err error) { if err := c.sendCmd("STAT"); err != nil { return 0, 0, fmt.Errorf("pop3 stat send: %w", err) } line, err := c.readLine() if err != nil { return 0, 0, fmt.Errorf("pop3 stat read: %w", err) } // Response: "+OK count size" parts := strings.Fields(line) if len(parts) < 2 { return 0, 0, fmt.Errorf("pop3 stat: unexpected response: %q", line) } count, err = strconv.Atoi(parts[0]) if err != nil { return 0, 0, fmt.Errorf("pop3 stat count parse: %w", err) } if len(parts) >= 2 { size, _ = strconv.Atoi(parts[1]) } return count, size, nil } // List returns the message numbers available on the server. func (c *Client) List() ([]int, error) { if err := c.sendCmd("LIST"); err != nil { return nil, fmt.Errorf("pop3 list send: %w", err) } // Read status line if _, err := c.readLine(); err != nil { return nil, fmt.Errorf("pop3 list status: %w", err) } // Read multi-line response data, err := c.readMultiLine() if err != nil { return nil, fmt.Errorf("pop3 list multiline: %w", err) } var nums []int for _, line := range strings.Split(string(data), "\n") { line = strings.TrimSpace(line) if line == "" { continue } // Each line: "msgnum size" parts := strings.Fields(line) if len(parts) == 0 { continue } n, err := strconv.Atoi(parts[0]) if err != nil { continue } nums = append(nums, n) } return nums, nil } // Retr retrieves a message by its number and returns the raw RFC 2822 bytes. func (c *Client) Retr(num int) ([]byte, error) { if err := c.sendCmd(fmt.Sprintf("RETR %d", num)); err != nil { return nil, fmt.Errorf("pop3 retr send: %w", err) } // Read status line if _, err := c.readLine(); err != nil { return nil, fmt.Errorf("pop3 retr status: %w", err) } data, err := c.readMultiLine() if err != nil { return nil, fmt.Errorf("pop3 retr multiline: %w", err) } return data, nil } // Quit sends the QUIT command and waits for the server acknowledgement. func (c *Client) Quit() error { if err := c.sendCmd("QUIT"); err != nil { return fmt.Errorf("pop3 quit send: %w", err) } _, err := c.readLine() return err } // Close closes the underlying network connection. func (c *Client) Close() { c.conn.Close() } // sendCmd writes a POP3 command terminated with CRLF. func (c *Client) sendCmd(cmd string) error { _ = c.conn.SetWriteDeadline(time.Now().Add(rwTimeout)) _, err := fmt.Fprintf(c.conn, "%s\r\n", cmd) if err != nil { return fmt.Errorf("pop3 write: %w", err) } return nil } // readLine reads one response line from the server. // It strips the CRLF, verifies the +OK/-ERR prefix, and returns the // text after the status indicator (without the "+OK" / "-ERR" prefix). // An error is returned if the server replies with -ERR. func (c *Client) readLine() (string, error) { _ = c.conn.SetReadDeadline(time.Now().Add(rwTimeout)) line, err := c.reader.ReadString('\n') if err != nil { return "", fmt.Errorf("pop3 read line: %w", err) } line = strings.TrimRight(line, "\r\n") if strings.HasPrefix(line, "-ERR") { return "", fmt.Errorf("pop3 server error: %s", strings.TrimPrefix(line, "-ERR ")) } // Strip "+OK" prefix if strings.HasPrefix(line, "+OK") { return strings.TrimPrefix(strings.TrimPrefix(line, "+OK"), " "), nil } return line, nil } // readMultiLine reads a dot-stuffed multi-line POP3 response until the // terminating ".\r\n" line. Dot-unstuffing is applied: lines beginning // with ".." are returned with a single leading ".". func (c *Client) readMultiLine() ([]byte, error) { var buf bytes.Buffer for { _ = c.conn.SetReadDeadline(time.Now().Add(rwTimeout)) line, err := c.reader.ReadString('\n') if err != nil { return nil, fmt.Errorf("pop3 read multiline: %w", err) } // Terminator: a single dot on a line by itself if line == ".\r\n" || line == ".\n" { break } // Dot-unstuffing: RFC 1939 ยง3 โ€” lines beginning with ".." โ†’ "." if strings.HasPrefix(line, "..") { line = line[1:] } buf.WriteString(line) } return buf.Bytes(), nil }