mirror of
https://github.com/axllent/mailpit.git
synced 2025-01-08 00:39:22 +02:00
316 lines
7.9 KiB
Go
316 lines
7.9 KiB
Go
// Package pop3 is a simple POP3 server for Mailpit.
|
|
// By default it is disabled unless password credentials have been loaded.
|
|
//
|
|
// References: https://github.com/r0stig/golang-pop3 | https://github.com/inbucket/inbucket
|
|
// See RFC: https://datatracker.ietf.org/doc/html/rfc1939
|
|
package pop3
|
|
|
|
import (
|
|
"bufio"
|
|
"crypto/tls"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/axllent/mailpit/config"
|
|
"github.com/axllent/mailpit/internal/auth"
|
|
"github.com/axllent/mailpit/internal/logger"
|
|
"github.com/axllent/mailpit/internal/storage"
|
|
"github.com/axllent/mailpit/server/websockets"
|
|
)
|
|
|
|
const (
|
|
// AUTHORIZATION is the initial state
|
|
AUTHORIZATION = 1
|
|
// TRANSACTION is the state after login
|
|
TRANSACTION = 2
|
|
// UPDATE is the state before closing
|
|
UPDATE = 3
|
|
)
|
|
|
|
// Run will start the POP3 server if enabled
|
|
func Run() {
|
|
if auth.POP3Credentials == nil || config.POP3Listen == "" {
|
|
// POP3 server is disabled without authentication
|
|
return
|
|
}
|
|
|
|
var listener net.Listener
|
|
var err error
|
|
|
|
if config.POP3TLSCert != "" {
|
|
cer, err2 := tls.LoadX509KeyPair(config.POP3TLSCert, config.POP3TLSKey)
|
|
if err2 != nil {
|
|
logger.Log().Errorf("[pop3] %s", err2.Error())
|
|
return
|
|
}
|
|
|
|
tlsConfig := &tls.Config{
|
|
Certificates: []tls.Certificate{cer},
|
|
MinVersion: tls.VersionTLS12,
|
|
}
|
|
|
|
listener, err = tls.Listen("tcp", config.POP3Listen, tlsConfig)
|
|
} else {
|
|
// unencrypted
|
|
listener, err = net.Listen("tcp", config.POP3Listen)
|
|
}
|
|
|
|
if err != nil {
|
|
logger.Log().Errorf("[pop3] %s", err.Error())
|
|
return
|
|
}
|
|
|
|
logger.Log().Infof("[pop3] starting on %s", config.POP3Listen)
|
|
|
|
for {
|
|
conn, err := listener.Accept()
|
|
if err != nil {
|
|
logger.Log().Errorf("[pop3] accept error: %s", err.Error())
|
|
continue
|
|
}
|
|
|
|
// run as goroutine
|
|
go handleClient(conn)
|
|
}
|
|
}
|
|
|
|
type message struct {
|
|
ID string
|
|
Size float64
|
|
}
|
|
|
|
func handleClient(conn net.Conn) {
|
|
var (
|
|
user = ""
|
|
state = AUTHORIZATION // Start with AUTHORIZATION state
|
|
toDelete []string // Track messages marked for deletion
|
|
messages []message
|
|
)
|
|
|
|
defer func() {
|
|
if state == UPDATE {
|
|
for _, id := range toDelete {
|
|
_ = storage.DeleteMessages([]string{id})
|
|
}
|
|
if len(toDelete) > 0 {
|
|
// Update web UI to remove deleted messages
|
|
websockets.Broadcast("prune", nil)
|
|
}
|
|
}
|
|
|
|
if err := conn.Close(); err != nil {
|
|
logger.Log().Errorf("[pop3] %s", err.Error())
|
|
}
|
|
}()
|
|
|
|
reader := bufio.NewReader(conn)
|
|
|
|
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 {
|
|
// Set read deadline
|
|
if err := conn.SetReadDeadline(time.Now().Add(timeoutDuration)); err != nil {
|
|
logger.Log().Errorf("[pop3] %s", err.Error())
|
|
return
|
|
}
|
|
|
|
// Reads a line from the client
|
|
rawLine, err := reader.ReadString('\n')
|
|
if err != nil {
|
|
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
|
|
}
|
|
|
|
// Parses the command
|
|
cmd, args := getCommand(rawLine)
|
|
cmd = strings.ToUpper(cmd) // Commands in the POP3 are case-insensitive
|
|
|
|
logger.Log().Debugf("[pop3] received: %s (%s)", strings.TrimSpace(rawLine), conn.RemoteAddr().String())
|
|
|
|
switch cmd {
|
|
case "CAPA":
|
|
// List our capabilities per RFC2449
|
|
sendResponse(conn, "+OK capability list follows")
|
|
sendResponse(conn, "TOP")
|
|
sendResponse(conn, "USER")
|
|
sendResponse(conn, "UIDL")
|
|
sendResponse(conn, "IMPLEMENTATION Mailpit")
|
|
sendResponse(conn, ".")
|
|
case "USER":
|
|
if state == AUTHORIZATION {
|
|
if len(args) != 1 {
|
|
sendResponse(conn, "-ERR must supply a user")
|
|
return
|
|
}
|
|
sendResponse(conn, "+OK")
|
|
user = args[0]
|
|
} else {
|
|
sendResponse(conn, "-ERR user already specified")
|
|
}
|
|
case "PASS":
|
|
if state == AUTHORIZATION {
|
|
if user == "" {
|
|
sendResponse(conn, "-ERR must supply a user")
|
|
return
|
|
}
|
|
if len(args) != 1 {
|
|
sendResponse(conn, "-ERR must supply a password")
|
|
return
|
|
}
|
|
|
|
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")
|
|
}
|
|
case "STAT", "LIST", "UIDL", "RETR", "TOP", "NOOP", "DELE", "RSET":
|
|
if state == TRANSACTION {
|
|
handleTransactionCommand(conn, cmd, args, messages, &toDelete)
|
|
} else {
|
|
sendResponse(conn, "-ERR user not authenticated")
|
|
}
|
|
case "QUIT":
|
|
sendResponse(conn, "+OK goodbye")
|
|
state = UPDATE
|
|
return
|
|
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")
|
|
case "RSET":
|
|
*toDelete = []string{}
|
|
sendResponse(conn, "+OK")
|
|
default:
|
|
sendResponse(conn, "-ERR unknown command")
|
|
}
|
|
}
|