diff --git a/cmd/root.go b/cmd/root.go index a17d16c3e..14d3221d9 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -275,6 +275,7 @@ func initConfigFromEnv() { config.SMTPRelayConfig.Secret = os.Getenv("MP_SMTP_RELAY_SECRET") config.SMTPRelayConfig.ReturnPath = os.Getenv("MP_SMTP_RELAY_RETURN_PATH") config.SMTPRelayConfig.AllowedRecipients = os.Getenv("MP_SMTP_RELAY_ALLOWED_RECIPIENTS") + config.SMTPRelayConfig.BlockedRecipients = os.Getenv("MP_SMTP_RELAY_BLOCKED_RECIPIENTS") // POP3 server if len(os.Getenv("MP_POP3_BIND_ADDR")) > 0 { diff --git a/config/config.go b/config/config.go index c5c914b97..9d443b9cb 100644 --- a/config/config.go +++ b/config/config.go @@ -191,6 +191,9 @@ type SMTPRelayConfigStruct struct { ReturnPath string `yaml:"return-path"` // allow overriding the bounce address AllowedRecipients string `yaml:"allowed-recipients"` // regex, if set needs to match for mails to be relayed AllowedRecipientsRegexp *regexp.Regexp // compiled regexp using AllowedRecipients + BlockedRecipients string `yaml:"blocked-recipients"` // regex, if set prevents relating to these addresses + BlockedRecipientsRegexp *regexp.Regexp // compiled regexp using BlockedRecipients + // DEPRECATED 2024/03/12 RecipientAllowlist string `yaml:"recipient-allowlist"` } @@ -433,12 +436,12 @@ func VerifyConfig() error { if SMTPRelayAll { logger.Log().Warnf("[relay] ignoring smtp-relay-matching when smtp-relay-all is enabled") } else { - restrictRegexp, err := regexp.Compile(SMTPRelayMatching) + re, err := regexp.Compile(SMTPRelayMatching) if err != nil { return fmt.Errorf("[relay] failed to compile smtp-relay-matching regexp: %s", err.Error()) } - SMTPRelayMatchingRegexp = restrictRegexp + SMTPRelayMatchingRegexp = re logger.Log().Infof("[relay] auto-relaying new messages to recipients matching \"%s\" via %s:%d", SMTPRelayMatching, SMTPRelayConfig.Host, SMTPRelayConfig.Port) } @@ -525,14 +528,23 @@ func validateRelayConfig() error { logger.Log().Infof("[smtp] enabling message relaying via %s:%d", SMTPRelayConfig.Host, SMTPRelayConfig.Port) if SMTPRelayConfig.AllowedRecipients != "" { - allowlistRegexp, err := regexp.Compile(SMTPRelayConfig.AllowedRecipients) + re, err := regexp.Compile(SMTPRelayConfig.AllowedRecipients) if err != nil { return fmt.Errorf("[smtp] failed to compile relay recipient allowlist regexp: %s", err.Error()) } - SMTPRelayConfig.AllowedRecipientsRegexp = allowlistRegexp + SMTPRelayConfig.AllowedRecipientsRegexp = re logger.Log().Infof("[smtp] relay recipient allowlist is active with the following regexp: %s", SMTPRelayConfig.AllowedRecipients) + } + + if SMTPRelayConfig.BlockedRecipients != "" { + re, err := regexp.Compile(SMTPRelayConfig.BlockedRecipients) + if err != nil { + return fmt.Errorf("[smtp] failed to compile relay recipient blocklist regexp: %s", err.Error()) + } + SMTPRelayConfig.BlockedRecipientsRegexp = re + logger.Log().Infof("[smtp] relay recipient blocklist is active with the following regexp: %s", SMTPRelayConfig.BlockedRecipients) } return nil diff --git a/server/apiv1/api.go b/server/apiv1/api.go index 80888c0fd..7c55e0aa8 100644 --- a/server/apiv1/api.go +++ b/server/apiv1/api.go @@ -9,18 +9,13 @@ import ( "net/mail" "strconv" "strings" - "time" "github.com/axllent/mailpit/config" "github.com/axllent/mailpit/internal/htmlcheck" "github.com/axllent/mailpit/internal/linkcheck" - "github.com/axllent/mailpit/internal/logger" "github.com/axllent/mailpit/internal/spamassassin" "github.com/axllent/mailpit/internal/storage" - "github.com/axllent/mailpit/internal/tools" - "github.com/axllent/mailpit/server/smtpd" "github.com/gorilla/mux" - "github.com/lithammer/shortuuid/v4" ) // GetMessages returns a paginated list of messages as JSON @@ -523,135 +518,6 @@ func SetReadStatus(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("ok")) } -// ReleaseMessage (method: POST) will release a message via a pre-configured external SMTP server. -func ReleaseMessage(w http.ResponseWriter, r *http.Request) { - // swagger:route POST /api/v1/message/{ID}/release message ReleaseMessage - // - // # Release message - // - // Release a message via a pre-configured external SMTP server. This is only enabled if message relaying has been configured. - // - // Consumes: - // - application/json - // - // Produces: - // - text/plain - // - // Schemes: http, https - // - // Responses: - // 200: OKResponse - // default: ErrorResponse - - vars := mux.Vars(r) - - id := vars["id"] - - msg, err := storage.GetMessageRaw(id) - if err != nil { - fourOFour(w) - return - } - - decoder := json.NewDecoder(r.Body) - - data := releaseMessageRequestBody{} - - if err := decoder.Decode(&data); err != nil { - httpError(w, err.Error()) - return - } - - for _, to := range data.To { - address, err := mail.ParseAddress(to) - - if err != nil { - httpError(w, "Invalid email address: "+to) - return - } - - if config.SMTPRelayConfig.AllowedRecipientsRegexp != nil && !config.SMTPRelayConfig.AllowedRecipientsRegexp.MatchString(address.Address) { - httpError(w, "Mail address does not match allowlist: "+to) - return - } - } - - if len(data.To) == 0 { - httpError(w, "No valid addresses found") - return - } - - reader := bytes.NewReader(msg) - m, err := mail.ReadMessage(reader) - if err != nil { - httpError(w, err.Error()) - return - } - - fromAddresses, err := m.Header.AddressList("From") - if err != nil { - httpError(w, err.Error()) - return - } - - if len(fromAddresses) == 0 { - httpError(w, "No From header found") - return - } - - from := fromAddresses[0].Address - - // if sender is used, then change from to the sender - if senders, err := m.Header.AddressList("Sender"); err == nil { - from = senders[0].Address - } - - msg, err = tools.RemoveMessageHeaders(msg, []string{"Bcc"}) - if err != nil { - httpError(w, err.Error()) - return - } - - // set the Return-Path and SMTP mfrom - if config.SMTPRelayConfig.ReturnPath != "" { - if m.Header.Get("Return-Path") != "<"+config.SMTPRelayConfig.ReturnPath+">" { - msg, err = tools.RemoveMessageHeaders(msg, []string{"Return-Path"}) - if err != nil { - httpError(w, err.Error()) - return - } - msg = append([]byte("Return-Path: <"+config.SMTPRelayConfig.ReturnPath+">\r\n"), msg...) - } - - from = config.SMTPRelayConfig.ReturnPath - } - - // update message date - msg, err = tools.UpdateMessageHeader(msg, "Date", time.Now().Format(time.RFC1123Z)) - if err != nil { - httpError(w, err.Error()) - return - } - - // generate unique ID - uid := shortuuid.New() + "@mailpit" - // update Message-Id with unique ID - msg, err = tools.UpdateMessageHeader(msg, "Message-Id", "<"+uid+">") - if err != nil { - httpError(w, err.Error()) - return - } - - if err := smtpd.Send(from, data.To, msg); err != nil { - logger.Log().Errorf("[smtp] error sending message: %s", err.Error()) - httpError(w, "SMTP error: "+err.Error()) - return - } - - w.Header().Add("Content-Type", "text/plain") - _, _ = w.Write([]byte("ok")) -} - // HTMLCheck returns a summary of the HTML client support func HTMLCheck(w http.ResponseWriter, r *http.Request) { // swagger:route GET /api/v1/message/{ID}/html-check Other HTMLCheck diff --git a/server/apiv1/release.go b/server/apiv1/release.go new file mode 100644 index 000000000..a406920df --- /dev/null +++ b/server/apiv1/release.go @@ -0,0 +1,167 @@ +package apiv1 + +import ( + "bytes" + "encoding/json" + "net/http" + "net/mail" + "strings" + "time" + + "github.com/axllent/mailpit/config" + "github.com/axllent/mailpit/internal/logger" + "github.com/axllent/mailpit/internal/storage" + "github.com/axllent/mailpit/internal/tools" + "github.com/axllent/mailpit/server/smtpd" + "github.com/gorilla/mux" + "github.com/lithammer/shortuuid/v4" +) + +// ReleaseMessage (method: POST) will release a message via a pre-configured external SMTP server. +func ReleaseMessage(w http.ResponseWriter, r *http.Request) { + // swagger:route POST /api/v1/message/{ID}/release message ReleaseMessage + // + // # Release message + // + // Release a message via a pre-configured external SMTP server. This is only enabled if message relaying has been configured. + // + // Consumes: + // - application/json + // + // Produces: + // - text/plain + // + // Schemes: http, https + // + // Responses: + // 200: OKResponse + // default: ErrorResponse + + vars := mux.Vars(r) + + id := vars["id"] + + msg, err := storage.GetMessageRaw(id) + if err != nil { + fourOFour(w) + return + } + + decoder := json.NewDecoder(r.Body) + + data := releaseMessageRequestBody{} + + if err := decoder.Decode(&data); err != nil { + httpError(w, err.Error()) + return + } + + blocked := []string{} + notAllowed := []string{} + + for _, to := range data.To { + address, err := mail.ParseAddress(to) + + if err != nil { + httpError(w, "Invalid email address: "+to) + return + } + + if config.SMTPRelayConfig.AllowedRecipientsRegexp != nil && !config.SMTPRelayConfig.AllowedRecipientsRegexp.MatchString(address.Address) { + notAllowed = append(notAllowed, to) + continue + } + + if config.SMTPRelayConfig.BlockedRecipientsRegexp != nil && config.SMTPRelayConfig.BlockedRecipientsRegexp.MatchString(address.Address) { + blocked = append(blocked, to) + continue + } + } + + if len(notAllowed) > 0 { + addr := tools.Plural(len(notAllowed), "Address", "Addresses") + httpError(w, "Failed: "+addr+" do not match the allowlist: "+strings.Join(notAllowed, ", ")) + return + } + + if len(blocked) > 0 { + addr := tools.Plural(len(blocked), "Address", "Addresses") + httpError(w, "Failed: "+addr+" found on blocklist: "+strings.Join(blocked, ", ")) + return + } + + if len(data.To) == 0 { + httpError(w, "No valid addresses found") + return + } + + reader := bytes.NewReader(msg) + m, err := mail.ReadMessage(reader) + if err != nil { + httpError(w, err.Error()) + return + } + + fromAddresses, err := m.Header.AddressList("From") + if err != nil { + httpError(w, err.Error()) + return + } + + if len(fromAddresses) == 0 { + httpError(w, "No From header found") + return + } + + from := fromAddresses[0].Address + + // if sender is used, then change from to the sender + if senders, err := m.Header.AddressList("Sender"); err == nil { + from = senders[0].Address + } + + msg, err = tools.RemoveMessageHeaders(msg, []string{"Bcc"}) + if err != nil { + httpError(w, err.Error()) + return + } + + // set the Return-Path and SMTP from + if config.SMTPRelayConfig.ReturnPath != "" { + if m.Header.Get("Return-Path") != "<"+config.SMTPRelayConfig.ReturnPath+">" { + msg, err = tools.RemoveMessageHeaders(msg, []string{"Return-Path"}) + if err != nil { + httpError(w, err.Error()) + return + } + msg = append([]byte("Return-Path: <"+config.SMTPRelayConfig.ReturnPath+">\r\n"), msg...) + } + + from = config.SMTPRelayConfig.ReturnPath + } + + // update message date + msg, err = tools.UpdateMessageHeader(msg, "Date", time.Now().Format(time.RFC1123Z)) + if err != nil { + httpError(w, err.Error()) + return + } + + // generate unique ID + uid := shortuuid.New() + "@mailpit" + // update Message-Id with unique ID + msg, err = tools.UpdateMessageHeader(msg, "Message-Id", "<"+uid+">") + if err != nil { + httpError(w, err.Error()) + return + } + + if err := smtpd.Send(from, data.To, msg); err != nil { + logger.Log().Errorf("[smtp] error sending message: %s", err.Error()) + httpError(w, "SMTP error: "+err.Error()) + return + } + + w.Header().Add("Content-Type", "text/plain") + _, _ = w.Write([]byte("ok")) +} diff --git a/server/apiv1/webui.go b/server/apiv1/webui.go index 10f86cd89..e506f59d5 100644 --- a/server/apiv1/webui.go +++ b/server/apiv1/webui.go @@ -24,6 +24,8 @@ type webUIConfiguration struct { ReturnPath string // Only allow relaying to these recipients (regex) AllowedRecipients string + // Block relaying to these recipients (regex) + BlockedRecipients string // DEPRECATED 2024/03/12 // swagger:ignore RecipientAllowlist string @@ -61,6 +63,7 @@ func WebUIConfig(w http.ResponseWriter, _ *http.Request) { conf.MessageRelay.SMTPServer = fmt.Sprintf("%s:%d", config.SMTPRelayConfig.Host, config.SMTPRelayConfig.Port) conf.MessageRelay.ReturnPath = config.SMTPRelayConfig.ReturnPath conf.MessageRelay.AllowedRecipients = config.SMTPRelayConfig.AllowedRecipients + conf.MessageRelay.BlockedRecipients = config.SMTPRelayConfig.BlockedRecipients // DEPRECATED 2024/03/12 conf.MessageRelay.RecipientAllowlist = config.SMTPRelayConfig.AllowedRecipients } diff --git a/server/smtpd/relay.go b/server/smtpd/relay.go index 534b2b7fc..e715d3ba5 100644 --- a/server/smtpd/relay.go +++ b/server/smtpd/relay.go @@ -8,6 +8,19 @@ import ( ) func autoRelayMessage(from string, to []string, data *[]byte) { + if config.SMTPRelayConfig.BlockedRecipientsRegexp != nil { + filteredTo := []string{} + for _, address := range to { + if config.SMTPRelayConfig.BlockedRecipientsRegexp.MatchString(address) { + logger.Log().Debugf("[smtp] ignoring auto-relay to %s: found in blocklist", address) + continue + } + + filteredTo = append(filteredTo, address) + } + to = filteredTo + } + if len(to) == 0 { return } diff --git a/server/ui-src/components/message/Release.vue b/server/ui-src/components/message/Release.vue index ab9cd84f7..6c7b39339 100644 --- a/server/ui-src/components/message/Release.vue +++ b/server/ui-src/components/message/Release.vue @@ -75,7 +75,7 @@ export default {