diff --git a/server/pop3/functions.go b/server/pop3/functions.go index 0bd62af..8fe7f91 100644 --- a/server/pop3/functions.go +++ b/server/pop3/functions.go @@ -72,5 +72,5 @@ func getSafeArg(args []string, nr int) (string, error) { if nr < len(args) { return args[nr], nil } - return "", errors.New("Out of range") + return "", errors.New("-ERR out of range") } diff --git a/server/pop3/pop3.go b/server/pop3/pop3.go index 7c5fc04..72dc77b 100644 --- a/server/pop3/pop3.go +++ b/server/pop3/pop3.go @@ -9,6 +9,7 @@ import ( "bufio" "crypto/tls" "fmt" + "io" "net" "strconv" "strings" @@ -22,15 +23,12 @@ import ( ) const ( - // UNAUTHORIZED state - UNAUTHORIZED = 1 - // TRANSACTION state - TRANSACTION = 2 - // UPDATE state - UPDATE = 3 + AUTHORIZATION = 1 + TRANSACTION = 2 + UPDATE = 3 ) -// Run will start the pop3 server if enabled +// Run will start the POP3 server if enabled func Run() { if auth.POP3Credentials == nil || config.POP3Listen == "" { // POP3 server is disabled without authentication @@ -68,6 +66,7 @@ func Run() { for { conn, err := listener.Accept() if err != nil { + logger.Log().Errorf("[pop3] accept error: %s", err.Error()) continue } @@ -84,8 +83,9 @@ type message struct { func handleClient(conn net.Conn) { var ( user = "" - state = 1 - toDelete = []string{} + state = AUTHORIZATION // Start with AUTHORIZATION state + toDelete []string // Track messages marked for deletion + messages []message ) defer func() { @@ -94,7 +94,7 @@ func handleClient(conn net.Conn) { _ = storage.DeleteMessages([]string{id}) } if len(toDelete) > 0 { - // update web UI to remove deleted messages + // Update web UI to remove deleted messages websockets.Broadcast("prune", nil) } } @@ -106,23 +106,17 @@ func handleClient(conn net.Conn) { reader := bufio.NewReader(conn) - messages := []message{} - - // State - // 1 = Unauthorized - // 2 = Transaction mode - // 3 = update mode - logger.Log().Debugf("[pop3] connection opened by %s", conn.RemoteAddr().String()) // First welcome the new connection sendResponse(conn, "+OK Mailpit POP3 server") + // Set 10 minutes timeout according to RFC1939 timeoutDuration := 600 * time.Second for { - // POP3 server enforced a timeout of 30 seconds - if err := conn.SetDeadline(time.Now().Add(timeoutDuration)); err != nil { + // Set read deadline + if err := conn.SetReadDeadline(time.Now().Add(timeoutDuration)); err != nil { logger.Log().Errorf("[pop3] %s", err.Error()) return } @@ -130,7 +124,11 @@ func handleClient(conn net.Conn) { // Reads a line from the client rawLine, err := reader.ReadString('\n') if err != nil { - logger.Log().Errorf("[pop3] %s", err.Error()) + if err == io.EOF { + logger.Log().Debugf("[pop3] client disconnected: %s", conn.RemoteAddr().String()) + } else { + logger.Log().Errorf("[pop3] read error: %s", err.Error()) + } return } @@ -139,7 +137,8 @@ func handleClient(conn net.Conn) { logger.Log().Debugf("[pop3] received: %s (%s)", strings.TrimSpace(rawLine), conn.RemoteAddr().String()) - if cmd == "CAPA" { + switch cmd { + case "CAPA": // List our capabilities per RFC2449 sendResponse(conn, "+OK Capability list follows") sendResponse(conn, "TOP") @@ -147,178 +146,159 @@ func handleClient(conn net.Conn) { sendResponse(conn, "UIDL") sendResponse(conn, "IMPLEMENTATION Mailpit") sendResponse(conn, ".") - continue - } else if cmd == "USER" && state == UNAUTHORIZED { - if len(args) != 1 { - sendResponse(conn, "-ERR must supply a user") - return - } - // always true - stash for PASS - sendResponse(conn, "+OK") - user = args[0] - - } else if cmd == "PASS" && state == UNAUTHORIZED { - if len(args) != 1 { - sendResponse(conn, "-ERR must supply a password") - return - } - - pass := args[0] - if authUser(user, pass) { - sendResponse(conn, "+OK signed in") - messages, err = getMessages() - if err != nil { - logger.Log().Errorf("[pop3] %s", err.Error()) + case "USER": + if state == AUTHORIZATION { + if len(args) != 1 { + sendResponse(conn, "-ERR must supply a user") + return } - state = 2 + sendResponse(conn, "+OK") + user = args[0] } else { - sendResponse(conn, "-ERR invalid password") - logger.Log().Warnf("[pop3] failed login: %s", user) + sendResponse(conn, "-ERR user already specified") } + case "PASS": + if state == AUTHORIZATION { + if len(args) != 1 { + sendResponse(conn, "-ERR must supply a password") + return + } - } else if cmd == "STAT" && state == TRANSACTION { - totalSize := float64(0) - for _, m := range messages { - totalSize = totalSize + m.Size + pass := args[0] + if authUser(user, pass) { + sendResponse(conn, "+OK signed in") + var err error + messages, err = getMessages() + if err != nil { + logger.Log().Errorf("[pop3] %s", err.Error()) + } + state = TRANSACTION + } else { + sendResponse(conn, "-ERR invalid password") + logger.Log().Warnf("[pop3] failed login: %s", user) + } + } else { + sendResponse(conn, "-ERR user not specified") } - - sendResponse(conn, fmt.Sprintf("+OK %d %d", len(messages), int64(totalSize))) - - } else if cmd == "LIST" && state == TRANSACTION { - totalSize := float64(0) - for _, m := range messages { - totalSize = totalSize + m.Size + case "STAT", "LIST", "UIDL", "RETR", "TOP", "NOOP", "DELE": + if state == TRANSACTION { + handleTransactionCommand(conn, cmd, args, messages, &toDelete) + } else { + sendResponse(conn, "-ERR user not authenticated") } - sendData(conn, fmt.Sprintf("+OK %d messages (%d octets)", len(messages), int64(totalSize))) - - // print all sizes - for row, m := range messages { - sendData(conn, fmt.Sprintf("%d %d", row+1, int64(m.Size))) // Convert Size to int64 when printing - } - // end - sendData(conn, ".") - - } else if cmd == "UIDL" && state == TRANSACTION { - totalSize := float64(0) - for _, m := range messages { - totalSize = totalSize + m.Size - } - - sendData(conn, "+OK unique-id listing follows") - - // print all message IDS - for row, m := range messages { - sendData(conn, fmt.Sprintf("%d %s", row+1, m.ID)) - } - // end - sendData(conn, ".") - - } else if cmd == "RETR" && state == TRANSACTION { - if len(args) != 1 { - sendResponse(conn, "-ERR no such message") - return - } - - nr, err := strconv.Atoi(args[0]) - if err != nil { - sendResponse(conn, "-ERR no such message") - return - } - - if nr < 1 || nr > len(messages) { - sendResponse(conn, "-ERR no such message") - return - } - - m := messages[nr-1] - raw, err := storage.GetMessageRaw(m.ID) - if err != nil { - sendResponse(conn, "-ERR no such message") - return - } - - size := len(raw) - sendData(conn, fmt.Sprintf("+OK %d octets", size)) - - // When all lines of the response have been sent, a - // final line is sent, consisting of a termination octet (decimal code - // 046, ".") and a CRLF pair. If any line of the multi-line response - // begins with the termination octet, the line is "byte-stuffed" by - // pre-pending the termination octet to that line of the response. - // @see: https://www.ietf.org/rfc/rfc1939.txt - sendData(conn, strings.Replace(string(raw), "\n.", "\n..", -1)) - sendData(conn, ".") - - } else if cmd == "TOP" && state == TRANSACTION { - arg, err := getSafeArg(args, 0) - if err != nil { - sendResponse(conn, "-ERR TOP requires two arguments") - return - } - nr, err := strconv.Atoi(arg) - if err != nil { - sendResponse(conn, "-ERR TOP requires two arguments") - return - } - - if nr < 1 || nr > len(messages) { - sendResponse(conn, "-ERR no such message") - return - } - arg2, err := getSafeArg(args, 1) - if err != nil { - sendResponse(conn, "-ERR TOP requires two arguments") - return - } - - lines, err := strconv.Atoi(arg2) - if err != nil { - sendResponse(conn, "-ERR TOP requires two arguments") - return - } - - m := messages[nr-1] - headers, body, err := getTop(m.ID, lines) - if err != nil { - sendResponse(conn, err.Error()) - return - } - - sendData(conn, "+OK Top of message follows") - sendData(conn, headers+"\r\n") - sendData(conn, body) - sendData(conn, ".") - - } else if cmd == "NOOP" && state == TRANSACTION { - sendData(conn, "+OK") - } else if cmd == "DELE" && state == TRANSACTION { - arg, _ := getSafeArg(args, 0) - nr, err := strconv.Atoi(arg) - if err != nil { - logger.Log().Warnf("[pop3] -ERR invalid DELETE integer: %s", arg) - sendResponse(conn, "-ERR invalid integer") - return - } - - if nr < 1 || nr > len(messages) { - logger.Log().Warnf("[pop3] -ERR no such message") - sendResponse(conn, "-ERR no such message") - return - } - toDelete = append(toDelete, messages[nr-1].ID) - - sendResponse(conn, "+OK") - - } else if cmd == "RSET" && state == TRANSACTION { - toDelete = []string{} - sendData(conn, "+OK") - - } else if cmd == "QUIT" { + case "QUIT": + sendResponse(conn, "+OK Goodbye") state = UPDATE return - } else { - logger.Log().Warnf("[pop3] -ERR %s not implemented", cmd) - sendResponse(conn, fmt.Sprintf("-ERR %s not implemented", cmd)) + default: + sendResponse(conn, "-ERR unknown command") } } } + +func handleTransactionCommand(conn net.Conn, cmd string, args []string, messages []message, toDelete *[]string) { + switch cmd { + case "STAT": + totalSize := float64(0) + for _, m := range messages { + totalSize += m.Size + } + sendResponse(conn, fmt.Sprintf("+OK %d %d", len(messages), int64(totalSize))) + case "LIST": + totalSize := float64(0) + for _, m := range messages { + totalSize += m.Size + } + sendResponse(conn, fmt.Sprintf("+OK %d messages (%d octets)", len(messages), int64(totalSize))) + + for row, m := range messages { + sendResponse(conn, fmt.Sprintf("%d %d", row+1, int64(m.Size))) // Convert Size to int64 when printing + } + sendResponse(conn, ".") + case "UIDL": + sendResponse(conn, "+OK unique-id listing follows") + for row, m := range messages { + sendResponse(conn, fmt.Sprintf("%d %s", row+1, m.ID)) + } + sendResponse(conn, ".") + case "RETR": + if len(args) != 1 { + sendResponse(conn, "-ERR no such message") + return + } + + nr, err := strconv.Atoi(args[0]) + if err != nil || nr < 1 || nr > len(messages) { + sendResponse(conn, "-ERR no such message") + return + } + + m := messages[nr-1] + raw, err := storage.GetMessageRaw(m.ID) + if err != nil { + sendResponse(conn, "-ERR no such message") + return + } + size := len(raw) + sendResponse(conn, fmt.Sprintf("+OK %d octets", size)) + + // When all lines of the response have been sent, a + // final line is sent, consisting of a termination octet (decimal code + // 046, ".") and a CRLF pair. If any line of the multi-line response + // begins with the termination octet, the line is "byte-stuffed" by + // pre-pending the termination octet to that line of the response. + // @see: https://www.ietf.org/rfc/rfc1939.txt + sendData(conn, strings.Replace(string(raw), "\n.", "\n..", -1)) + sendResponse(conn, ".") + case "TOP": + arg, err := getSafeArg(args, 0) + if err != nil { + sendResponse(conn, "-ERR TOP requires two arguments") + return + } + nr, err := strconv.Atoi(arg) + if err != nil || nr < 1 || nr > len(messages) { + sendResponse(conn, "-ERR no such message") + return + } + + arg2, err := getSafeArg(args, 1) + if err != nil { + sendResponse(conn, "-ERR TOP requires two arguments") + return + } + + lines, err := strconv.Atoi(arg2) + if err != nil { + sendResponse(conn, "-ERR TOP requires two arguments") + return + } + + m := messages[nr-1] + headers, body, err := getTop(m.ID, lines) + if err != nil { + sendResponse(conn, err.Error()) + return + } + + sendResponse(conn, "+OK Top of message follows") + sendData(conn, headers+"\r\n") + sendData(conn, body) + sendResponse(conn, ".") + case "NOOP": + sendResponse(conn, "+OK") + case "DELE": + arg, _ := getSafeArg(args, 0) + nr, err := strconv.Atoi(arg) + if err != nil || nr < 1 || nr > len(messages) { + sendResponse(conn, "-ERR no such message") + return + } + + m := messages[nr-1] + *toDelete = append(*toDelete, m.ID) + sendResponse(conn, "+OK message marked for deletion") + default: + sendResponse(conn, "-ERR unknown command") + } +}