mirror of
https://github.com/jhillyerd/inbucket.git
synced 2025-12-17 17:47:03 +00:00
add reject from origin domain feature (#375)
Add a new feature to be able to reject email *from* specific domains. Co-authored-by: Cyril DUPONT <cyd@9bis.com>
This commit is contained in:
@@ -74,21 +74,22 @@ type Lua struct {
|
|||||||
|
|
||||||
// SMTP contains the SMTP server configuration.
|
// SMTP contains the SMTP server configuration.
|
||||||
type SMTP struct {
|
type SMTP struct {
|
||||||
Addr string `required:"true" default:"0.0.0.0:2500" desc:"SMTP server IP4 host:port"`
|
Addr string `required:"true" default:"0.0.0.0:2500" desc:"SMTP server IP4 host:port"`
|
||||||
Domain string `required:"true" default:"inbucket" desc:"HELO domain"`
|
Domain string `required:"true" default:"inbucket" desc:"HELO domain"`
|
||||||
MaxRecipients int `required:"true" default:"200" desc:"Maximum RCPT TO per message"`
|
MaxRecipients int `required:"true" default:"200" desc:"Maximum RCPT TO per message"`
|
||||||
MaxMessageBytes int `required:"true" default:"10240000" desc:"Maximum message size"`
|
MaxMessageBytes int `required:"true" default:"10240000" desc:"Maximum message size"`
|
||||||
DefaultAccept bool `required:"true" default:"true" desc:"Accept all mail by default?"`
|
DefaultAccept bool `required:"true" default:"true" desc:"Accept all mail by default?"`
|
||||||
AcceptDomains []string `desc:"Domains to accept mail for"`
|
AcceptDomains []string `desc:"Domains to accept mail for"`
|
||||||
RejectDomains []string `desc:"Domains to reject mail for"`
|
RejectDomains []string `desc:"Domains to reject mail for"`
|
||||||
DefaultStore bool `required:"true" default:"true" desc:"Store all mail by default?"`
|
DefaultStore bool `required:"true" default:"true" desc:"Store all mail by default?"`
|
||||||
StoreDomains []string `desc:"Domains to store mail for"`
|
StoreDomains []string `desc:"Domains to store mail for"`
|
||||||
DiscardDomains []string `desc:"Domains to discard mail for"`
|
DiscardDomains []string `desc:"Domains to discard mail for"`
|
||||||
Timeout time.Duration `required:"true" default:"300s" desc:"Idle network timeout"`
|
RejectOriginDomains []string `desc:"Domains to reject mail from"`
|
||||||
TLSEnabled bool `default:"false" desc:"Enable STARTTLS option"`
|
Timeout time.Duration `required:"true" default:"300s" desc:"Idle network timeout"`
|
||||||
TLSPrivKey string `default:"cert.key" desc:"X509 Private Key file for TLS Support"`
|
TLSEnabled bool `default:"false" desc:"Enable STARTTLS option"`
|
||||||
TLSCert string `default:"cert.crt" desc:"X509 Public Certificate file for TLS Support"`
|
TLSPrivKey string `default:"cert.key" desc:"X509 Private Key file for TLS Support"`
|
||||||
Debug bool `ignored:"true"`
|
TLSCert string `default:"cert.crt" desc:"X509 Public Certificate file for TLS Support"`
|
||||||
|
Debug bool `ignored:"true"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// POP3 contains the POP3 server configuration.
|
// POP3 contains the POP3 server configuration.
|
||||||
@@ -128,6 +129,7 @@ func Process() (*Root, error) {
|
|||||||
stringutil.SliceToLower(c.SMTP.RejectDomains)
|
stringutil.SliceToLower(c.SMTP.RejectDomains)
|
||||||
stringutil.SliceToLower(c.SMTP.StoreDomains)
|
stringutil.SliceToLower(c.SMTP.StoreDomains)
|
||||||
stringutil.SliceToLower(c.SMTP.DiscardDomains)
|
stringutil.SliceToLower(c.SMTP.DiscardDomains)
|
||||||
|
stringutil.SliceToLower(c.SMTP.RejectOriginDomains)
|
||||||
return c, err
|
return c, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import (
|
|||||||
type Manager interface {
|
type Manager interface {
|
||||||
Deliver(
|
Deliver(
|
||||||
to *policy.Recipient,
|
to *policy.Recipient,
|
||||||
from string,
|
from *policy.Origin,
|
||||||
recipients []*policy.Recipient,
|
recipients []*policy.Recipient,
|
||||||
prefix string,
|
prefix string,
|
||||||
content []byte,
|
content []byte,
|
||||||
@@ -43,7 +43,7 @@ type StoreManager struct {
|
|||||||
// Deliver submits a new message to the store.
|
// Deliver submits a new message to the store.
|
||||||
func (s *StoreManager) Deliver(
|
func (s *StoreManager) Deliver(
|
||||||
to *policy.Recipient,
|
to *policy.Recipient,
|
||||||
from string,
|
from *policy.Origin,
|
||||||
recipients []*policy.Recipient,
|
recipients []*policy.Recipient,
|
||||||
prefix string,
|
prefix string,
|
||||||
source []byte,
|
source []byte,
|
||||||
@@ -56,7 +56,8 @@ func (s *StoreManager) Deliver(
|
|||||||
}
|
}
|
||||||
fromaddr, err := env.AddressList("From")
|
fromaddr, err := env.AddressList("From")
|
||||||
if err != nil || len(fromaddr) == 0 {
|
if err != nil || len(fromaddr) == 0 {
|
||||||
fromaddr = []*mail.Address{{Address: from}}
|
fromaddr = make([]*mail.Address, 1)
|
||||||
|
fromaddr[0] = &from.Address
|
||||||
}
|
}
|
||||||
toaddr, err := env.AddressList("To")
|
toaddr, err := env.AddressList("To")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -22,9 +22,10 @@ func TestManagerEmitsMessageStoredEvent(t *testing.T) {
|
|||||||
listener := extHost.Events.AfterMessageStored.AsyncTestListener("manager", 1)
|
listener := extHost.Events.AfterMessageStored.AsyncTestListener("manager", 1)
|
||||||
|
|
||||||
// Attempt to deliver a message to generate event.
|
// Attempt to deliver a message to generate event.
|
||||||
|
origin, _ := sm.AddrPolicy.ParseOrigin("from@example.com")
|
||||||
if _, err := sm.Deliver(
|
if _, err := sm.Deliver(
|
||||||
&policy.Recipient{},
|
&policy.Recipient{},
|
||||||
"from@example.com",
|
origin,
|
||||||
[]*policy.Recipient{},
|
[]*policy.Recipient{},
|
||||||
"prefix",
|
"prefix",
|
||||||
[]byte("From: from@example.com\n\ntest email"),
|
[]byte("From: from@example.com\n\ntest email"),
|
||||||
|
|||||||
@@ -71,6 +71,21 @@ func (a *Addressing) NewRecipient(address string) (*Recipient, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ParseOrigin parses an address into a Origin. This is used for parsing MAIL FROM argument,
|
||||||
|
// not To headers.
|
||||||
|
func (a *Addressing) ParseOrigin(address string) (*Origin, error) {
|
||||||
|
local, domain, err := ParseEmailAddress(address)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Origin{
|
||||||
|
Address: mail.Address{Address: address},
|
||||||
|
addrPolicy: a,
|
||||||
|
LocalPart: local,
|
||||||
|
Domain: domain,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ShouldAcceptDomain indicates if Inbucket accepts mail destined for the specified domain.
|
// ShouldAcceptDomain indicates if Inbucket accepts mail destined for the specified domain.
|
||||||
func (a *Addressing) ShouldAcceptDomain(domain string) bool {
|
func (a *Addressing) ShouldAcceptDomain(domain string) bool {
|
||||||
domain = strings.ToLower(domain)
|
domain = strings.ToLower(domain)
|
||||||
@@ -99,6 +114,12 @@ func (a *Addressing) ShouldStoreDomain(domain string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ShouldAcceptOriginDomain indicates if Inbucket accept mail from the specified domain.
|
||||||
|
func (a *Addressing) ShouldAcceptOriginDomain(domain string) bool {
|
||||||
|
domain = strings.ToLower(domain)
|
||||||
|
return !stringutil.SliceContains(a.Config.SMTP.RejectOriginDomains, domain)
|
||||||
|
}
|
||||||
|
|
||||||
// ParseEmailAddress unescapes an email address, and splits the local part from the domain part.
|
// ParseEmailAddress unescapes an email address, and splits the local part from the domain part.
|
||||||
// An error is returned if the local or domain parts fail validation following the guidelines
|
// An error is returned if the local or domain parts fail validation following the guidelines
|
||||||
// in RFC3696.
|
// in RFC3696.
|
||||||
|
|||||||
20
pkg/policy/origin.go
Normal file
20
pkg/policy/origin.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package policy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/mail"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Origin represents a potential email origin, allows policies for it to be queried.
|
||||||
|
type Origin struct {
|
||||||
|
mail.Address
|
||||||
|
addrPolicy *Addressing
|
||||||
|
// LocalPart is the part of the address before @, including +extension.
|
||||||
|
LocalPart string
|
||||||
|
// Domain is the part of the address after @.
|
||||||
|
Domain string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShouldAccept returns true if Inbucket should accept mail from this origin.
|
||||||
|
func (o *Origin) ShouldAccept() bool {
|
||||||
|
return o.addrPolicy.ShouldAcceptOriginDomain(o.Domain)
|
||||||
|
}
|
||||||
@@ -106,7 +106,7 @@ type Session struct {
|
|||||||
sendError error // Last network send error.
|
sendError error // Last network send error.
|
||||||
state State // Session state machine.
|
state State // Session state machine.
|
||||||
reader *bufio.Reader // Buffered reading for TCP conn.
|
reader *bufio.Reader // Buffered reading for TCP conn.
|
||||||
from string // Sender from MAIL command.
|
from *policy.Origin // Sender from MAIL command.
|
||||||
recipients []*policy.Recipient // Recipients from RCPT commands.
|
recipients []*policy.Recipient // Recipients from RCPT commands.
|
||||||
logger zerolog.Logger // Session specific logger.
|
logger zerolog.Logger // Session specific logger.
|
||||||
debug bool // Print network traffic to stdout.
|
debug bool // Print network traffic to stdout.
|
||||||
@@ -384,7 +384,10 @@ func (s *Session) readyHandler(cmd string, arg string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
from := m[1]
|
from := m[1]
|
||||||
|
s.logger.Debug().Msgf("Mail sender is %v", from)
|
||||||
localpart, domain, err := policy.ParseEmailAddress(from)
|
localpart, domain, err := policy.ParseEmailAddress(from)
|
||||||
|
s.logger.Debug().Msgf("Origin domain is %v", domain)
|
||||||
|
|
||||||
if from != "" && err != nil {
|
if from != "" && err != nil {
|
||||||
s.send("501 Bad sender address syntax")
|
s.send("501 Bad sender address syntax")
|
||||||
s.logger.Warn().Msgf("Bad address as MAIL arg: %q, %s", from, err)
|
s.logger.Warn().Msgf("Bad address as MAIL arg: %q, %s", from, err)
|
||||||
@@ -424,7 +427,19 @@ func (s *Session) readyHandler(cmd string, arg string) {
|
|||||||
|
|
||||||
if extResult == nil || *extResult {
|
if extResult == nil || *extResult {
|
||||||
// Permitted by extension, or none had an opinion.
|
// Permitted by extension, or none had an opinion.
|
||||||
s.from = from
|
origin, err := s.addrPolicy.ParseOrigin(from)
|
||||||
|
if err != nil {
|
||||||
|
s.send("501 Bad origin address syntax")
|
||||||
|
s.logger.Warn().Str("from", from).Err(err).Msg("Bad address as MAIL arg")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.from = origin
|
||||||
|
if !s.from.ShouldAccept() {
|
||||||
|
s.send("501 Unauthorized domain")
|
||||||
|
s.logger.Warn().Msgf("Bad domain sender %s", domain)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
s.logger.Info().Msgf("Mail from: %v", from)
|
s.logger.Info().Msgf("Mail from: %v", from)
|
||||||
s.send(fmt.Sprintf("250 Roger, accepting mail from <%v>", from))
|
s.send(fmt.Sprintf("250 Roger, accepting mail from <%v>", from))
|
||||||
s.enterState(MAIL)
|
s.enterState(MAIL)
|
||||||
@@ -601,6 +616,7 @@ func (s *Session) readLine() (line string, err error) {
|
|||||||
|
|
||||||
func (s *Session) parseCmd(line string) (cmd string, arg string, ok bool) {
|
func (s *Session) parseCmd(line string) (cmd string, arg string, ok bool) {
|
||||||
line = strings.TrimRight(line, "\r\n")
|
line = strings.TrimRight(line, "\r\n")
|
||||||
|
s.logger.Debug().Msgf("Line received: %v", line)
|
||||||
|
|
||||||
// Find length of command or entire line.
|
// Find length of command or entire line.
|
||||||
hasArg := true
|
hasArg := true
|
||||||
@@ -649,7 +665,7 @@ func (s *Session) parseArgs(arg string) (args map[string]string, ok bool) {
|
|||||||
|
|
||||||
func (s *Session) reset() {
|
func (s *Session) reset() {
|
||||||
s.enterState(READY)
|
s.enterState(READY)
|
||||||
s.from = ""
|
s.from = nil
|
||||||
s.recipients = nil
|
s.recipients = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ func TestEmptyEnvelope(t *testing.T) {
|
|||||||
// Test out some empty envelope without blanks
|
// Test out some empty envelope without blanks
|
||||||
script := []scriptStep{
|
script := []scriptStep{
|
||||||
{"HELO localhost", 250},
|
{"HELO localhost", 250},
|
||||||
{"MAIL FROM:<>", 250},
|
{"MAIL FROM:<>", 501},
|
||||||
}
|
}
|
||||||
if err := playSession(t, server, script); err != nil {
|
if err := playSession(t, server, script); err != nil {
|
||||||
t.Error(err)
|
t.Error(err)
|
||||||
@@ -101,7 +101,7 @@ func TestEmptyEnvelope(t *testing.T) {
|
|||||||
// Test out some empty envelope with blanks
|
// Test out some empty envelope with blanks
|
||||||
script = []scriptStep{
|
script = []scriptStep{
|
||||||
{"HELO localhost", 250},
|
{"HELO localhost", 250},
|
||||||
{"MAIL FROM: <>", 250},
|
{"MAIL FROM: <>", 501},
|
||||||
}
|
}
|
||||||
if err := playSession(t, server, script); err != nil {
|
if err := playSession(t, server, script); err != nil {
|
||||||
t.Error(err)
|
t.Error(err)
|
||||||
@@ -198,6 +198,31 @@ func TestReadyStateValidCommands(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Test invalid domains in READY state.
|
||||||
|
func TestReadyStateRejectedDomains(t *testing.T) {
|
||||||
|
ds := test.NewStore()
|
||||||
|
server := setupSMTPServer(ds, extension.NewHost())
|
||||||
|
|
||||||
|
tests := []scriptStep{
|
||||||
|
{"MAIL FROM: <john@validdomain.com>", 250},
|
||||||
|
{"MAIL FROM: <john@invalidomain.com>", 501},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.send, func(t *testing.T) {
|
||||||
|
defer server.Drain()
|
||||||
|
script := []scriptStep{
|
||||||
|
{"HELO localhost", 250},
|
||||||
|
tc,
|
||||||
|
{"QUIT", 221}}
|
||||||
|
if err := playSession(t, server, script); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
// Test invalid commands in READY state.
|
// Test invalid commands in READY state.
|
||||||
func TestReadyStateInvalidCommands(t *testing.T) {
|
func TestReadyStateInvalidCommands(t *testing.T) {
|
||||||
ds := test.NewStore()
|
ds := test.NewStore()
|
||||||
@@ -557,6 +582,7 @@ func setupSMTPServer(ds storage.Store, extHost *extension.Host) *Server {
|
|||||||
MaxMessageBytes: 5000,
|
MaxMessageBytes: 5000,
|
||||||
DefaultAccept: true,
|
DefaultAccept: true,
|
||||||
RejectDomains: []string{"deny.com"},
|
RejectDomains: []string{"deny.com"},
|
||||||
|
RejectOriginDomains: []string{"invalidomain.com"},
|
||||||
Timeout: 5,
|
Timeout: 5,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,13 +36,14 @@ func RootStatus(w http.ResponseWriter, req *http.Request, ctx *web.Context) (err
|
|||||||
POP3Listener: root.POP3.Addr,
|
POP3Listener: root.POP3.Addr,
|
||||||
WebListener: root.Web.Addr,
|
WebListener: root.Web.Addr,
|
||||||
SMTPConfig: jsonSMTPConfig{
|
SMTPConfig: jsonSMTPConfig{
|
||||||
Addr: root.SMTP.Addr,
|
Addr: root.SMTP.Addr,
|
||||||
DefaultAccept: root.SMTP.DefaultAccept,
|
DefaultAccept: root.SMTP.DefaultAccept,
|
||||||
AcceptDomains: root.SMTP.AcceptDomains,
|
AcceptDomains: root.SMTP.AcceptDomains,
|
||||||
RejectDomains: root.SMTP.RejectDomains,
|
RejectDomains: root.SMTP.RejectDomains,
|
||||||
DefaultStore: root.SMTP.DefaultStore,
|
DefaultStore: root.SMTP.DefaultStore,
|
||||||
StoreDomains: root.SMTP.StoreDomains,
|
StoreDomains: root.SMTP.StoreDomains,
|
||||||
DiscardDomains: root.SMTP.DiscardDomains,
|
DiscardDomains: root.SMTP.DiscardDomains,
|
||||||
|
RejectOriginDomains: root.SMTP.RejectOriginDomains,
|
||||||
},
|
},
|
||||||
StorageConfig: jsonStorageConfig{
|
StorageConfig: jsonStorageConfig{
|
||||||
MailboxMsgCap: root.Storage.MailboxMsgCap,
|
MailboxMsgCap: root.Storage.MailboxMsgCap,
|
||||||
|
|||||||
@@ -10,13 +10,14 @@ type jsonServerConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type jsonSMTPConfig struct {
|
type jsonSMTPConfig struct {
|
||||||
Addr string `json:"addr"`
|
Addr string `json:"addr"`
|
||||||
DefaultAccept bool `json:"default-accept"`
|
DefaultAccept bool `json:"default-accept"`
|
||||||
AcceptDomains []string `json:"accept-domains"`
|
AcceptDomains []string `json:"accept-domains"`
|
||||||
RejectDomains []string `json:"reject-domains"`
|
RejectDomains []string `json:"reject-domains"`
|
||||||
DefaultStore bool `json:"default-store"`
|
DefaultStore bool `json:"default-store"`
|
||||||
StoreDomains []string `json:"store-domains"`
|
StoreDomains []string `json:"store-domains"`
|
||||||
DiscardDomains []string `json:"discard-domains"`
|
DiscardDomains []string `json:"discard-domains"`
|
||||||
|
RejectOriginDomains []string `json:"reject-origin-domains"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type jsonStorageConfig struct {
|
type jsonStorageConfig struct {
|
||||||
|
|||||||
Reference in New Issue
Block a user