From a7d2b00a9c7060173b61a075ff8430da2df0f99a Mon Sep 17 00:00:00 2001 From: James Hillyerd Date: Sun, 1 Apr 2018 18:05:21 -0700 Subject: [PATCH 1/2] 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) + } +} From 064549f576010d9b26b2b2c3f3a245845e781e2c Mon Sep 17 00:00:00 2001 From: James Hillyerd Date: Sun, 1 Apr 2018 19:46:44 -0700 Subject: [PATCH 2/2] policy: Implement store domain policy for #51 - Update docs, change log, status.html --- CHANGELOG.md | 6 +++- doc/config.md | 62 ++++++++++++++++++++------------- pkg/config/config.go | 9 ++--- pkg/policy/address.go | 10 ++++-- pkg/policy/address_test.go | 25 ++++++------- pkg/server/smtp/handler_test.go | 2 -- pkg/server/smtp/listener.go | 5 --- pkg/server/web/helpers.go | 1 + ui/templates/root/status.html | 24 +++++++++++-- 9 files changed, 91 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 127330b..e793ff8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ This project adheres to [Semantic Versioning](http://semver.org/). - RedHat `.rpm` package generation to release process. - Message seen flag in REST and Web UI so you can see which messages have already been read. +- Recipient domain accept policy; Inbucket can now reject mail to specific + domains. ### Changed - Massive refactor of back-end code. Inbucket should now be both easier and @@ -34,9 +36,11 @@ This project adheres to [Semantic Versioning](http://semver.org/). flag; which is enabled by default for the Docker container. - SMTP and POP3 network tracing is no longer logged regardless of level, but can be sent to stdout via `-netdebug` flag. +- Replaced store/nostore config variables with a storage policy that mirrors the + domain accept policy. ### Removed -- Support for SIGHUP and log file rotation. +- No longer support SIGHUP or log file rotation. ## [v1.3.1] - 2018-03-10 diff --git a/doc/config.md b/doc/config.md index fcb9d5e..cab55fc 100644 --- a/doc/config.md +++ b/doc/config.md @@ -11,13 +11,14 @@ variables it supports: INBUCKET_LOGLEVEL INFO DEBUG, INFO, WARN, or ERROR INBUCKET_SMTP_ADDR 0.0.0.0:2500 SMTP server IP4 host:port INBUCKET_SMTP_DOMAIN inbucket HELO domain - INBUCKET_SMTP_DOMAINNOSTORE Load testing domain 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_DEFAULTSTORE true Store all mail by default? + INBUCKET_SMTP_STOREDOMAINS Domains to store mail for + INBUCKET_SMTP_DISCARDDOMAINS Domains to discard 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 @@ -78,17 +79,6 @@ Most SMTP clients appear to ignore this value. - Default: `inbucket` -### Load Testing/No Store Domain - -`INBUCKET_SMTP_DOMAINNOSTORE` - -Mail sent to this domain will not be stored by Inbucket. This is helpful if you -are load or soak testing a service, and do not plan to inspect the resulting -emails. Messages sent to a domain other than this will be stored normally. - -- Default: None -- Example: `bitbucket.local` - ### Maximum Recipients `INBUCKET_SMTP_MAXRECIPIENTS` @@ -108,17 +98,6 @@ exceeding this size will be rejected during the SMTP `DATA` phase. - Default: `10240000` (10MB) -### Store Messages - -`INBUCKET_SMTP_STOREMESSAGES` - -This option can be used to disable mail storage entirely. Useful for load -testing, or turning Inbucket into a black hole that will consume our entire -solar system. - -- Default: `true` -- Values: `true` or `false` - ### Default Recipient Accept Policy `INBUCKET_SMTP_DEFAULTACCEPT` @@ -152,6 +131,41 @@ has no effect when false. - Values: Comma separated list of domains - Example: `reject.com,gmail.com` +### Default Recipient Store Policy + +`INBUCKET_SMTP_DEFAULTSTORE` + +If true, Inbucket will store mail sent to any domain unless present in the +discard domains list. If false, messages will be discarded unless their domain +is present in the store domains list. + +- Default: `true` +- Values: `true` or `false` + +### Stored Recipient Domain List + +`INBUCKET_SMTP_STOREDOMAINS` + +List of domains to store mail for when `INBUCKET_SMTP_DEFAULTSTORE` is false; +has no effect when true. + +- Default: None +- Values: Comma separated list of domains +- Example: `localhost,mysite.org` + +### Discarded Recipient Domain List + +`INBUCKET_SMTP_DISCARDDOMAINS` + +Mail sent to these domains will not be stored by Inbucket. This is helpful if +you are load or soak testing a service, and do not plan to inspect the resulting +emails. Messages sent to a domain other than this will be stored normally. +Only has an effect when `INBUCKET_SMTP_DEFAULTSTORE` is true. + +- Default: None +- Values: Comma separated list of domains +- Example: `recycle.com,loadtest.org` + ### Network Idle Timeout `INBUCKET_SMTP_TIMEOUT` diff --git a/pkg/config/config.go b/pkg/config/config.go index 0fc23b8..1bb6057 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -3,7 +3,6 @@ package config import ( "log" "os" - "strings" "text/tabwriter" "time" @@ -42,13 +41,14 @@ type Root struct { type SMTP struct { 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"` - DomainNoStore string `desc:"Load testing domain"` 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"` + DefaultStore bool `required:"true" default:"true" desc:"Store all mail by default?"` + StoreDomains []string `desc:"Domains to store mail for"` + DiscardDomains []string `desc:"Domains to discard mail for"` Timeout time.Duration `required:"true" default:"300s" desc:"Idle network timeout"` Debug bool `ignored:"true"` } @@ -86,9 +86,10 @@ type Storage struct { 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) + stringutil.SliceToLower(c.SMTP.StoreDomains) + stringutil.SliceToLower(c.SMTP.DiscardDomains) return c, err } diff --git a/pkg/policy/address.go b/pkg/policy/address.go index 6941eb3..f8cded9 100644 --- a/pkg/policy/address.go +++ b/pkg/policy/address.go @@ -50,10 +50,14 @@ func (a *Addressing) ShouldAcceptDomain(domain string) bool { return false } -// ShouldStoreDomain indicates if Inbucket stores email destined for the specified domain. +// ShouldStoreDomain indicates if Inbucket stores mail destined for the specified domain. func (a *Addressing) ShouldStoreDomain(domain string) bool { - if a.Config.StoreMessages { - return strings.ToLower(domain) != strings.ToLower(a.Config.DomainNoStore) + domain = strings.ToLower(domain) + if a.Config.DefaultStore && !stringutil.SliceContains(a.Config.DiscardDomains, domain) { + return true + } + if !a.Config.DefaultStore && stringutil.SliceContains(a.Config.StoreDomains, domain) { + return true } return false } diff --git a/pkg/policy/address_test.go b/pkg/policy/address_test.go index 19bfaf7..4410f11 100644 --- a/pkg/policy/address_test.go +++ b/pkg/policy/address_test.go @@ -65,24 +65,24 @@ func TestShouldStoreDomain(t *testing.T) { // Test with storage enabled. ap := &policy.Addressing{ Config: config.SMTP{ - DomainNoStore: "Foo.Com", - StoreMessages: true, + DefaultStore: false, + StoreDomains: []string{"store.com", "a.store.com"}, }, } testCases := []struct { domain string want bool }{ - {domain: "bar.com", want: true}, {domain: "foo.com", want: false}, - {domain: "FOO.com", want: false}, - {domain: "bar.foo.com", want: true}, + {domain: "STORE.com", want: true}, + {domain: "a.store.com", want: true}, + {domain: "b.store.com", want: false}, } for _, tc := range testCases { t.Run(tc.domain, func(t *testing.T) { got := ap.ShouldStoreDomain(tc.domain) if got != tc.want { - t.Errorf("Got %v for %q, want: %v", got, tc.domain, tc.want) + t.Errorf("Got store %v for %q, want: %v", got, tc.domain, tc.want) } }) @@ -90,23 +90,24 @@ func TestShouldStoreDomain(t *testing.T) { // Test with storage disabled. ap = &policy.Addressing{ Config: config.SMTP{ - StoreMessages: false, + DefaultStore: true, + DiscardDomains: []string{"discard.com", "a.discard.com"}, }, } testCases = []struct { domain string want bool }{ - {domain: "bar.com", want: false}, - {domain: "foo.com", want: false}, - {domain: "FOO.com", want: false}, - {domain: "bar.foo.com", want: false}, + {domain: "foo.com", want: true}, + {domain: "DISCARD.com", want: false}, + {domain: "a.discard.com", want: false}, + {domain: "b.discard.com", want: true}, } for _, tc := range testCases { t.Run(tc.domain, func(t *testing.T) { got := ap.ShouldStoreDomain(tc.domain) if got != tc.want { - t.Errorf("Got %v for %q, want: %v", got, tc.domain, tc.want) + t.Errorf("Got store %v for %q, want: %v", got, tc.domain, tc.want) } }) diff --git a/pkg/server/smtp/handler_test.go b/pkg/server/smtp/handler_test.go index e648868..e4d441a 100644 --- a/pkg/server/smtp/handler_test.go +++ b/pkg/server/smtp/handler_test.go @@ -365,10 +365,8 @@ func setupSMTPServer(ds storage.Store) (s *Server, buf *bytes.Buffer, teardown f cfg := config.SMTP{ Addr: "127.0.0.1:2500", Domain: "inbucket.local", - DomainNoStore: "bitbucket.local", MaxRecipients: 5, MaxMessageBytes: 5000, - StoreMessages: true, DefaultAccept: true, RejectDomains: []string{"deny.com"}, Timeout: 5, diff --git a/pkg/server/smtp/listener.go b/pkg/server/smtp/listener.go index 3741eef..9161d75 100644 --- a/pkg/server/smtp/listener.go +++ b/pkg/server/smtp/listener.go @@ -97,11 +97,6 @@ func (s *Server) Start(ctx context.Context) { s.emergencyShutdown() return } - if !s.config.StoreMessages { - slog.Info().Msg("Load test mode active, messages will not be stored") - } else if s.config.DomainNoStore != "" { - slog.Info().Msgf("Messages sent to domain '%v' will be discarded", s.config.DomainNoStore) - } // Listener go routine. go s.serve(ctx) // Wait for shutdown. diff --git a/pkg/server/web/helpers.go b/pkg/server/web/helpers.go index 60d2edc..ea32cb2 100644 --- a/pkg/server/web/helpers.go +++ b/pkg/server/web/helpers.go @@ -15,6 +15,7 @@ import ( var TemplateFuncs = template.FuncMap{ "friendlyTime": FriendlyTime, "reverse": Reverse, + "stringsJoin": strings.Join, "textToHtml": TextToHTML, } diff --git a/ui/templates/root/status.html b/ui/templates/root/status.html index f666ee7..3f1d976 100644 --- a/ui/templates/root/status.html +++ b/ui/templates/root/status.html @@ -52,10 +52,30 @@ $(document).ready(
{{.webListener}}
-
No-Store Domain:
+
Accept Policy:
{{with .smtpConfig}} - {{or .DomainNoStore .DomainNoStore "Not Configured"}} + + {{if .DefaultAccept}} + All domains{{with .RejectDomains}}, except: {{stringsJoin . ", "}}{{end}} + {{else}} + No domains{{with .AcceptDomains}}, except: {{stringsJoin . ", "}}{{end}} + {{end}} + + {{end}} +
+
+
+
Store Policy:
+
+ {{with .smtpConfig}} + + {{if .DefaultStore}} + All domains{{with .DiscardDomains}}, except: {{stringsJoin . ", "}}{{end}} + {{else}} + No domains{{with .StoreDomains}}, except: {{stringsJoin . ", "}}{{end}} + {{end}} + {{end}}