Skip to content

Commit

Permalink
Feature: Add optional relay recipient blocklist (#333)
Browse files Browse the repository at this point in the history
  • Loading branch information
axllent committed Jul 14, 2024
1 parent 406fe56 commit 6947c2a
Show file tree
Hide file tree
Showing 8 changed files with 235 additions and 141 deletions.
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
20 changes: 16 additions & 4 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
Expand Down
134 changes: 0 additions & 134 deletions server/apiv1/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
167 changes: 167 additions & 0 deletions server/apiv1/release.go
Original file line number Diff line number Diff line change
@@ -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"))
}
3 changes: 3 additions & 0 deletions server/apiv1/webui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
Loading

0 comments on commit 6947c2a

Please sign in to comment.