From f548bbb874754fb71f9129a231ffea28501d888b Mon Sep 17 00:00:00 2001 From: Ralph Slooten Date: Sat, 24 Feb 2024 23:10:48 +1300 Subject: [PATCH 1/2] Feature: Optional POP3 server (#249) Originally requested in #72 --- Dockerfile | 2 +- cmd/root.go | 17 ++ config/config.go | 55 ++++++ internal/auth/auth.go | 21 +++ internal/storage/messages.go | 6 +- server/pop3/functions.go | 76 +++++++++ server/pop3/pop3.go | 314 +++++++++++++++++++++++++++++++++++ server/server.go | 3 + 8 files changed, 491 insertions(+), 3 deletions(-) create mode 100644 server/pop3/functions.go create mode 100644 server/pop3/pop3.go diff --git a/Dockerfile b/Dockerfile index aff301b..13e7517 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,6 @@ COPY --from=builder /mailpit /mailpit RUN apk add --no-cache tzdata -EXPOSE 1025/tcp 8025/tcp +EXPOSE 1025/tcp 1110/tcp 8025/tcp ENTRYPOINT ["/mailpit"] diff --git a/cmd/root.go b/cmd/root.go index f962978..e517dee 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -110,6 +110,12 @@ func init() { rootCmd.Flags().StringVar(&config.SMTPRelayConfigFile, "smtp-relay-config", config.SMTPRelayConfigFile, "SMTP configuration file to allow releasing messages") rootCmd.Flags().BoolVar(&config.SMTPRelayAllIncoming, "smtp-relay-all", config.SMTPRelayAllIncoming, "Relay all incoming messages via external SMTP server (caution!)") + + rootCmd.Flags().StringVar(&config.POP3Listen, "pop3", config.POP3Listen, "POP3 server bind interface and port") + rootCmd.Flags().StringVar(&config.POP3AuthFile, "pop3-auth-file", config.POP3AuthFile, "A password file for POP3 server authentication (enables POP3 server)") + rootCmd.Flags().StringVar(&config.POP3TLSCert, "pop3-tls-cert", config.POP3TLSCert, "Optional TLS certificate for POP3 server - requires pop3-tls-key") + rootCmd.Flags().StringVar(&config.POP3TLSKey, "pop3-tls-key", config.POP3TLSKey, "Optional TLS key for POP3 server - requires pop3-tls-cert") + rootCmd.Flags().StringVar(&config.WebhookURL, "webhook-url", config.WebhookURL, "Send a webhook request for new messages") rootCmd.Flags().IntVar(&webhook.RateLimit, "webhook-limit", webhook.RateLimit, "Limit webhook requests per second") @@ -195,6 +201,17 @@ func initConfigFromEnv() { config.SMTPRelayAllIncoming = true } + // POP3 + if len(os.Getenv("MP_POP3_BIND_ADDR")) > 0 { + config.POP3Listen = os.Getenv("MP_POP3_BIND_ADDR") + } + config.POP3AuthFile = os.Getenv("MP_POP3_AUTH_FILE") + if err := auth.SetPOP3Auth(os.Getenv("MP_POP3_AUTH")); err != nil { + logger.Log().Errorf(err.Error()) + } + config.POP3TLSCert = os.Getenv("MP_POP3_TLS_CERT") + config.POP3TLSKey = os.Getenv("MP_POP3_TLS_KEY") + // Webhook if len(os.Getenv("MP_WEBHOOK_URL")) > 0 { config.WebhookURL = os.Getenv("MP_WEBHOOK_URL") diff --git a/config/config.go b/config/config.go index 9b5ad8c..6e48680 100644 --- a/config/config.go +++ b/config/config.go @@ -4,6 +4,7 @@ package config import ( "errors" "fmt" + "net" "net/url" "os" "path" @@ -112,6 +113,18 @@ var ( // Use with extreme caution! SMTPRelayAllIncoming = false + // POP3Listen address - if set then Mailpit will start the POP3 server and listen on this address + POP3Listen = "[::]:1110" + + // POP3AuthFile for POP3 authentication + POP3AuthFile string + + // POP3TLSCert TLS certificate + POP3TLSCert string + + // POP3TLSKey TLS certificate key + POP3TLSKey string + // EnableSpamAssassin must be either : or "postmark" EnableSpamAssassin string @@ -201,6 +214,7 @@ func VerifyConfig() error { if UITLSCert != "" { UITLSCert = filepath.Clean(UITLSCert) + UITLSKey = filepath.Clean(UITLSKey) if !isFile(UITLSCert) { return fmt.Errorf("[ui] TLS certificate not found: %s", UITLSCert) @@ -217,6 +231,7 @@ func VerifyConfig() error { if SMTPTLSCert != "" { SMTPTLSCert = filepath.Clean(SMTPTLSCert) + SMTPTLSKey = filepath.Clean(SMTPTLSKey) if !isFile(SMTPTLSCert) { return fmt.Errorf("[smtp] TLS certificate not found: %s", SMTPTLSCert) @@ -258,6 +273,46 @@ func VerifyConfig() error { return errors.New("[smtp] authentication requires TLS encryption, run with `--smtp-auth-allow-insecure` to allow insecure authentication") } + // POP3 server + if POP3TLSCert != "" { + POP3TLSCert = filepath.Clean(POP3TLSCert) + POP3TLSKey = filepath.Clean(POP3TLSKey) + + if !isFile(POP3TLSCert) { + return fmt.Errorf("[pop3] TLS certificate not found: %s", POP3TLSCert) + } + + if !isFile(POP3TLSKey) { + return fmt.Errorf("[pop3] TLS key not found: %s", POP3TLSKey) + } + } + if POP3TLSCert != "" && POP3TLSKey == "" || POP3TLSCert == "" && POP3TLSKey != "" { + return errors.New("[pop3] You must provide both a POP3 TLS certificate and a key") + } + if POP3Listen != "" { + _, err := net.ResolveTCPAddr("tcp", POP3Listen) + if err != nil { + return fmt.Errorf("[pop3] %s", err.Error()) + } + } + if POP3AuthFile != "" { + POP3AuthFile = filepath.Clean(POP3AuthFile) + + if !isFile(POP3AuthFile) { + return fmt.Errorf("[pop3] password file not found: %s", POP3AuthFile) + } + + b, err := os.ReadFile(POP3AuthFile) + if err != nil { + return err + } + + if err := auth.SetPOP3Auth(string(b)); err != nil { + return err + } + } + + // Web root validWebrootRe := regexp.MustCompile(`[^0-9a-zA-Z\/\-\_\.@]`) if validWebrootRe.MatchString(Webroot) { return fmt.Errorf("invalid characters in Webroot (%s). Valid chars include: [a-z A-Z 0-9 _ . - / @]", Webroot) diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 5bd9a9d..5225573 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -13,6 +13,8 @@ var ( UICredentials *htpasswd.File // SMTPCredentials passwords SMTPCredentials *htpasswd.File + // POP3Credentials passwords + POP3Credentials *htpasswd.File ) // SetUIAuth will set Basic Auth credentials required for the UI & API @@ -53,6 +55,25 @@ func SetSMTPAuth(s string) error { return nil } +// SetPOP3Auth will set POP3 server credentials +func SetPOP3Auth(s string) error { + var err error + + credentials := credentialsFromString(s) + if len(credentials) == 0 { + return nil + } + + r := strings.NewReader(strings.Join(credentials, "\n")) + + POP3Credentials, err = htpasswd.NewFromReader(r, htpasswd.DefaultSystems, nil) + if err != nil { + return err + } + + return nil +} + func credentialsFromString(s string) []string { // split string by any whitespace character re := regexp.MustCompile(`\s+`) diff --git a/internal/storage/messages.go b/internal/storage/messages.go index baa0036..d69a117 100644 --- a/internal/storage/messages.go +++ b/internal/storage/messages.go @@ -513,10 +513,12 @@ func MarkUnread(id string) error { // DeleteOneMessage will delete a single message from a mailbox func DeleteOneMessage(id string) error { - m, err := GetMessage(id) + m, err := GetMessageRaw(id) if err != nil { return err } + + size := len(m) // begin a transaction to ensure both the message // and data are deleted successfully tx, err := db.BeginTx(context.Background(), nil) @@ -548,7 +550,7 @@ func DeleteOneMessage(id string) error { } dbLastAction = time.Now() - addDeletedSize(int64(m.Size)) + addDeletedSize(int64(size)) logMessagesDeleted(1) diff --git a/server/pop3/functions.go b/server/pop3/functions.go new file mode 100644 index 0000000..0bd62af --- /dev/null +++ b/server/pop3/functions.go @@ -0,0 +1,76 @@ +package pop3 + +import ( + "errors" + "fmt" + "net" + "strings" + + "github.com/axllent/mailpit/internal/auth" + "github.com/axllent/mailpit/internal/logger" + "github.com/axllent/mailpit/internal/storage" +) + +func authUser(username, password string) bool { + return auth.POP3Credentials.Match(username, password) +} + +// Send a response with debug logging +func sendResponse(c net.Conn, m string) { + fmt.Fprintf(c, "%s\r\n", m) + logger.Log().Debugf("[pop3] response: %s", m) +} + +// Send a response without debug logging (for data) +func sendData(c net.Conn, m string) { + fmt.Fprintf(c, "%s\r\n", m) +} + +func getMessages() ([]message, error) { + messages := []message{} + list, err := storage.List(0, 100) + if err != nil { + return messages, err + } + + for _, m := range list { + msg := message{} + msg.ID = m.ID + msg.Size = m.Size + messages = append(messages, msg) + } + + return messages, nil +} + +// POP3 TOP command returns the headers, followed by the next x lines +func getTop(id string, nr int) (string, string, error) { + var header, body string + raw, err := storage.GetMessageRaw(id) + if err != nil { + return header, body, errors.New("-ERR no such message") + } + + parts := strings.SplitN(string(raw), "\r\n\r\n", 2) + header = parts[0] + lines := []string{} + if nr > 0 && len(parts) == 2 { + lines = strings.SplitN(parts[1], "\r\n", nr) + } + + return header, strings.Join(lines, "\r\n"), nil +} + +// cuts the line into command and arguments +func getCommand(line string) (string, []string) { + line = strings.Trim(line, "\r \n") + cmd := strings.Split(line, " ") + return cmd[0], cmd[1:] +} + +func getSafeArg(args []string, nr int) (string, error) { + if nr < len(args) { + return args[nr], nil + } + return "", errors.New("Out of range") +} diff --git a/server/pop3/pop3.go b/server/pop3/pop3.go new file mode 100644 index 0000000..43bf339 --- /dev/null +++ b/server/pop3/pop3.go @@ -0,0 +1,314 @@ +// 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" + "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 ( + // UNAUTHORIZED state + UNAUTHORIZED = 1 + // TRANSACTION state + TRANSACTION = 2 + // UPDATE state + 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, err := tls.LoadX509KeyPair(config.POP3TLSCert, config.POP3TLSKey) + if err != nil { + logger.Log().Errorf("[pop3] %s", err.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 { + continue + } + + // run as goroutine + go handleClient(conn) + } +} + +type message struct { + ID string + Size int +} + +func handleClient(conn net.Conn) { + + var ( + user = "" + state = 1 + toDelete = []string{} + ) + + defer func() { + if state == UPDATE { + for _, id := range toDelete { + _ = storage.DeleteOneMessage(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) + + 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") + + timeoutDuration := 30 * time.Second + + for { + // POP3 server enforced a timeout of 30 seconds + if err := conn.SetDeadline(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 { + logger.Log().Errorf("[pop3] %s", err.Error()) + return + } + + // Parses the command + cmd, args := getCommand(rawLine) + + logger.Log().Debugf("[pop3] received: %s (%s)", strings.TrimSpace(rawLine), conn.RemoteAddr().String()) + + if cmd == "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, ".") + 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()) + } + state = 2 + } else { + sendResponse(conn, "-ERR invalid password") + logger.Log().Warnf("[pop3] failed login: %s", user) + } + + } else if cmd == "STAT" && state == TRANSACTION { + totalSize := 0 + for _, m := range messages { + totalSize = totalSize + m.Size + } + + sendResponse(conn, fmt.Sprintf("+OK %d %d", len(messages), totalSize)) + + } else if cmd == "LIST" && state == TRANSACTION { + totalSize := 0 + for _, m := range messages { + totalSize = totalSize + m.Size + } + sendData(conn, fmt.Sprintf("+OK %d messages (%d octets)", len(messages), totalSize)) + + // print all sizes + for row, m := range messages { + sendData(conn, fmt.Sprintf("%d %d", row+1, m.Size)) + } + // end + sendData(conn, ".") + + } else if cmd == "UIDL" && state == TRANSACTION { + totalSize := 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)) + sendData(conn, string(raw)) + 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) + + 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" { + state = UPDATE + return + } else { + logger.Log().Warnf("[pop3] -ERR %s not implemented", cmd) + sendResponse(conn, fmt.Sprintf("-ERR %s not implemented", cmd)) + } + } +} diff --git a/server/server.go b/server/server.go index 5415838..4d0c9d1 100644 --- a/server/server.go +++ b/server/server.go @@ -22,6 +22,7 @@ import ( "github.com/axllent/mailpit/internal/storage" "github.com/axllent/mailpit/server/apiv1" "github.com/axllent/mailpit/server/handlers" + "github.com/axllent/mailpit/server/pop3" "github.com/axllent/mailpit/server/websockets" "github.com/gorilla/mux" ) @@ -48,6 +49,8 @@ func Listen() { go websockets.MessageHub.Run() + go pop3.Run() + r := apiRoutes() // kubernetes probes From e8c306b7ab1c06a0a0a41b83a1767da504b48085 Mon Sep 17 00:00:00 2001 From: Ralph Slooten Date: Sat, 24 Feb 2024 23:10:58 +1300 Subject: [PATCH 2/2] Update README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 938684b..26b6e51 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ via either HTTPS or `localhost` only) including an optional allowlist of accepted recipients - Fast SMTP processing & storing - approximately 70-100 emails per second depending on CPU, network speed & email size, easily handling tens of thousands of emails +- Optional [POP3 server](https://mailpit.axllent.org/docs/configuration/pop3/) to download captured message directly into your email client - Configurable automatic email pruning (default keeps the most recent 500 emails) - A simple [REST API](https://mailpit.axllent.org/docs/api-v1/) for integration testing - Optional [webhook](https://mailpit.axllent.org/docs/integration/webhook/) for received messages