feat(PROJ-14): POP3-Import — Client, Store, Importer, API-Routen, Frontend-Seite
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user