From a7d2b00a9c7060173b61a075ff8430da2df0f99a Mon Sep 17 00:00:00 2001 From: James Hillyerd Date: Sun, 1 Apr 2018 18:05:21 -0700 Subject: [PATCH] policy: Implement recipient domain policy for #51 - INBUCKET_SMTP_DEFAULTACCEPT - INBUCKET_SMTP_ACCEPTDOMAINS - INBUCKET_SMTP_REJECTDOMAINS --- doc/config.md | 36 ++++++++++++++++++++++ pkg/config/config.go | 6 ++++ pkg/policy/address.go | 23 ++++++++++---- pkg/policy/address_test.go | 53 +++++++++++++++++++++++++++++++++ pkg/policy/recipient.go | 6 ++-- pkg/server/smtp/handler.go | 5 ++++ pkg/server/smtp/handler_test.go | 5 +++- pkg/stringutil/utils.go | 18 +++++++++++ 8 files changed, 143 insertions(+), 9 deletions(-) diff --git a/doc/config.md b/doc/config.md index 8f8c5ba..fcb9d5e 100644 --- a/doc/config.md +++ b/doc/config.md @@ -15,6 +15,9 @@ variables it supports: INBUCKET_SMTP_MAXRECIPIENTS 200 Maximum RCPT TO per message INBUCKET_SMTP_MAXMESSAGEBYTES 10240000 Maximum message size INBUCKET_SMTP_STOREMESSAGES true Store incoming mail? + INBUCKET_SMTP_DEFAULTACCEPT true Accept all mail by default? + INBUCKET_SMTP_ACCEPTDOMAINS Domains to accept mail for + INBUCKET_SMTP_REJECTDOMAINS Domains to reject mail for INBUCKET_SMTP_TIMEOUT 300s Idle network timeout INBUCKET_POP3_ADDR 0.0.0.0:1100 POP3 server IP4 host:port INBUCKET_POP3_DOMAIN inbucket HELLO domain @@ -116,6 +119,39 @@ solar system. - Default: `true` - Values: `true` or `false` +### Default Recipient Accept Policy + +`INBUCKET_SMTP_DEFAULTACCEPT` + +If true, Inbucket will accept mail to any domain unless present in the reject +domains list. If false, recipients will be rejected unless their domain is +present in the accept domains list. + +- Default: `true` +- Values: `true` or `false` + +### Accepted Recipient Domain List + +`INBUCKET_SMTP_ACCEPTDOMAINS` + +List of domains to accept mail for when `INBUCKET_SMTP_DEFAULTACCEPT` is false; +has no effect when true. + +- Default: None +- Values: Comma separated list of domains +- Example: `localhost,mysite.org` + +### Rejected Recipient Domain List + +`INBUCKET_SMTP_REJECTDOMAINS` + +List of domains to reject mail for when `INBUCKET_SMTP_DEFAULTACCEPT` is true; +has no effect when false. + +- Default: None +- Values: Comma separated list of domains +- Example: `reject.com,gmail.com` + ### Network Idle Timeout `INBUCKET_SMTP_TIMEOUT` diff --git a/pkg/config/config.go b/pkg/config/config.go index 3dc7549..0fc23b8 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -7,6 +7,7 @@ import ( "text/tabwriter" "time" + "github.com/jhillyerd/inbucket/pkg/stringutil" "github.com/kelseyhightower/envconfig" ) @@ -45,6 +46,9 @@ type SMTP struct { MaxRecipients int `required:"true" default:"200" desc:"Maximum RCPT TO per message"` MaxMessageBytes int `required:"true" default:"10240000" desc:"Maximum message size"` StoreMessages bool `required:"true" default:"true" desc:"Store incoming mail?"` + DefaultAccept bool `required:"true" default:"true" desc:"Accept all mail by default?"` + AcceptDomains []string `desc:"Domains to accept mail for"` + RejectDomains []string `desc:"Domains to reject mail for"` Timeout time.Duration `required:"true" default:"300s" desc:"Idle network timeout"` Debug bool `ignored:"true"` } @@ -83,6 +87,8 @@ func Process() (*Root, error) { c := &Root{} err := envconfig.Process(prefix, c) c.SMTP.DomainNoStore = strings.ToLower(c.SMTP.DomainNoStore) + stringutil.SliceToLower(c.SMTP.AcceptDomains) + stringutil.SliceToLower(c.SMTP.RejectDomains) return c, err } diff --git a/pkg/policy/address.go b/pkg/policy/address.go index 680a789..6941eb3 100644 --- a/pkg/policy/address.go +++ b/pkg/policy/address.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/jhillyerd/inbucket/pkg/config" + "github.com/jhillyerd/inbucket/pkg/stringutil" ) // Addressing handles email address policy. @@ -29,14 +30,26 @@ func (a *Addressing) NewRecipient(address string) (*Recipient, error) { return nil, err } return &Recipient{ - Address: *ar, - apolicy: a, - LocalPart: local, - Domain: domain, - Mailbox: mailbox, + Address: *ar, + addrPolicy: a, + LocalPart: local, + Domain: domain, + Mailbox: mailbox, }, nil } +// ShouldAcceptDomain indicates if Inbucket accepts mail destined for the specified domain. +func (a *Addressing) ShouldAcceptDomain(domain string) bool { + domain = strings.ToLower(domain) + if a.Config.DefaultAccept && !stringutil.SliceContains(a.Config.RejectDomains, domain) { + return true + } + if !a.Config.DefaultAccept && stringutil.SliceContains(a.Config.AcceptDomains, domain) { + return true + } + return false +} + // ShouldStoreDomain indicates if Inbucket stores email destined for the specified domain. func (a *Addressing) ShouldStoreDomain(domain string) bool { if a.Config.StoreMessages { diff --git a/pkg/policy/address_test.go b/pkg/policy/address_test.go index 009033c..19bfaf7 100644 --- a/pkg/policy/address_test.go +++ b/pkg/policy/address_test.go @@ -8,6 +8,59 @@ import ( "github.com/jhillyerd/inbucket/pkg/policy" ) +func TestShouldAcceptDomain(t *testing.T) { + // Test with default accept. + ap := &policy.Addressing{ + Config: config.SMTP{ + DefaultAccept: true, + RejectDomains: []string{"a.deny.com", "deny.com"}, + }, + } + testCases := []struct { + domain string + want bool + }{ + {domain: "bar.com", want: true}, + {domain: "DENY.com", want: false}, + {domain: "a.deny.com", want: false}, + {domain: "b.deny.com", want: true}, + } + for _, tc := range testCases { + t.Run(tc.domain, func(t *testing.T) { + got := ap.ShouldAcceptDomain(tc.domain) + if got != tc.want { + t.Errorf("Got %v for %q, want: %v", got, tc.domain, tc.want) + } + + }) + } + // Test with default reject. + ap = &policy.Addressing{ + Config: config.SMTP{ + DefaultAccept: false, + AcceptDomains: []string{"a.allow.com", "allow.com"}, + }, + } + testCases = []struct { + domain string + want bool + }{ + {domain: "bar.com", want: false}, + {domain: "ALLOW.com", want: true}, + {domain: "a.allow.com", want: true}, + {domain: "b.allow.com", want: false}, + } + for _, tc := range testCases { + t.Run(tc.domain, func(t *testing.T) { + got := ap.ShouldAcceptDomain(tc.domain) + if got != tc.want { + t.Errorf("Got %v for %q, want: %v", got, tc.domain, tc.want) + } + + }) + } +} + func TestShouldStoreDomain(t *testing.T) { // Test with storage enabled. ap := &policy.Addressing{ diff --git a/pkg/policy/recipient.go b/pkg/policy/recipient.go index 36fd94b..860eebe 100644 --- a/pkg/policy/recipient.go +++ b/pkg/policy/recipient.go @@ -5,7 +5,7 @@ import "net/mail" // Recipient represents a potential email recipient, allows policies for it to be queried. type Recipient struct { mail.Address - apolicy *Addressing + addrPolicy *Addressing // LocalPart is the part of the address before @, including +extension. LocalPart string // Domain is the part of the address after @. @@ -16,10 +16,10 @@ type Recipient struct { // ShouldAccept returns true if Inbucket should accept mail for this recipient. func (r *Recipient) ShouldAccept() bool { - return true + return r.addrPolicy.ShouldAcceptDomain(r.Domain) } // ShouldStore returns true if Inbucket should store mail for this recipient. func (r *Recipient) ShouldStore() bool { - return r.apolicy.ShouldStoreDomain(r.Domain) + return r.addrPolicy.ShouldStoreDomain(r.Domain) } diff --git a/pkg/server/smtp/handler.go b/pkg/server/smtp/handler.go index 0e30417..706670c 100644 --- a/pkg/server/smtp/handler.go +++ b/pkg/server/smtp/handler.go @@ -329,6 +329,11 @@ func (s *Session) mailHandler(cmd string, arg string) { s.logger.Warn().Msgf("Bad address as RCPT arg: %q, %s", addr, err) return } + if !recip.ShouldAccept() { + s.logger.Warn().Str("addr", addr).Msg("Rejecting recipient") + s.send("550 Relay not permitted") + return + } if len(s.recipients) >= s.config.MaxRecipients { s.logger.Warn().Msgf("Maximum limit of %v recipients reached", s.config.MaxRecipients) diff --git a/pkg/server/smtp/handler_test.go b/pkg/server/smtp/handler_test.go index e1b75d8..e648868 100644 --- a/pkg/server/smtp/handler_test.go +++ b/pkg/server/smtp/handler_test.go @@ -169,6 +169,7 @@ func TestMailState(t *testing.T) { {"RCPT TO:", 250}, {"RCPT TO: ", 250}, {"RCPT TO:u3@gmail.com", 250}, + {"RCPT TO:u3@deny.com", 550}, {"RCPT TO: u4@gmail.com", 250}, {"RSET", 250}, {"MAIL FROM:", 250}, @@ -366,9 +367,11 @@ func setupSMTPServer(ds storage.Store) (s *Server, buf *bytes.Buffer, teardown f Domain: "inbucket.local", DomainNoStore: "bitbucket.local", MaxRecipients: 5, - Timeout: 5, MaxMessageBytes: 5000, StoreMessages: true, + DefaultAccept: true, + RejectDomains: []string{"deny.com"}, + Timeout: 5, } // Capture log output diff --git a/pkg/stringutil/utils.go b/pkg/stringutil/utils.go index 699eff8..afde694 100644 --- a/pkg/stringutil/utils.go +++ b/pkg/stringutil/utils.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "net/mail" + "strings" ) // HashMailboxName accepts a mailbox name and hashes it. filestore uses this as @@ -28,3 +29,20 @@ func StringAddressList(addrs []*mail.Address) []string { } return s } + +// SliceContains returns true if s is present in slice. +func SliceContains(slice []string, s string) bool { + for _, v := range slice { + if s == v { + return true + } + } + return false +} + +// SliceToLower lowercases the contents of slice of strings. +func SliceToLower(slice []string) { + for i, s := range slice { + slice[i] = strings.ToLower(s) + } +}