diff --git a/pkg/config/config.go b/pkg/config/config.go index e1c77e9..e1501d8 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -31,11 +31,12 @@ var ( // Root wraps all other configurations. type Root struct { - LogLevel string `required:"true" default:"info" desc:"debug, info, warn, or error"` - SMTP SMTP - POP3 POP3 - Web Web - Storage Storage + LogLevel string `required:"true" default:"info" desc:"debug, info, warn, or error"` + MailboxNaming string `required:"true" default:"local" desc:"local or full"` + SMTP SMTP + POP3 POP3 + Web Web + Storage Storage } // SMTP contains the SMTP server configuration. diff --git a/pkg/policy/address.go b/pkg/policy/address.go index be353c0..ce06d09 100644 --- a/pkg/policy/address.go +++ b/pkg/policy/address.go @@ -17,7 +17,24 @@ type Addressing struct { // ExtractMailbox extracts the mailbox name from a partial email address. func (a *Addressing) ExtractMailbox(address string) (string, error) { - return parseMailboxName(address) + local, domain, err := parseEmailAddress(address) + if err != nil { + return "", err + } + local, err = parseMailboxName(local) + if err != nil { + return "", err + } + if a.Config.MailboxNaming == "local" { + return local, nil + } + if domain == "" { + return local, nil + } + if !ValidateDomainPart(domain) { + return "", fmt.Errorf("Domain part %q in %q failed validation", domain, address) + } + return local + "@" + domain, nil } // NewRecipient parses an address into a Recipient. @@ -75,6 +92,69 @@ func (a *Addressing) ShouldStoreDomain(domain string) bool { // An error is returned if the local or domain parts fail validation following the guidelines // in RFC3696. func ParseEmailAddress(address string) (local string, domain string, err error) { + local, domain, err = parseEmailAddress(address) + if err != nil { + return "", "", err + } + if !ValidateDomainPart(domain) { + return "", "", fmt.Errorf("Domain part validation failed") + } + return local, domain, nil +} + +// ValidateDomainPart returns true if the domain part complies to RFC3696, RFC1035. Used by +// ParseEmailAddress(). +func ValidateDomainPart(domain string) bool { + if len(domain) == 0 { + return false + } + if len(domain) > 255 { + return false + } + if domain[len(domain)-1] != '.' { + domain += "." + } + prev := '.' + labelLen := 0 + hasAlphaNum := false + for _, c := range domain { + switch { + case ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z') || + ('0' <= c && c <= '9') || c == '_': + // Must contain some of these to be a valid label. + hasAlphaNum = true + labelLen++ + case c == '-': + if prev == '.' { + // Cannot lead with hyphen. + return false + } + case c == '.': + if prev == '.' || prev == '-' { + // Cannot end with hyphen or double-dot. + return false + } + if labelLen > 63 { + return false + } + if !hasAlphaNum { + return false + } + labelLen = 0 + hasAlphaNum = false + default: + // Unknown character. + return false + } + prev = c + } + return true +} + +// parseEmailAddress unescapes an email address, and splits the local part from the domain part. An +// error is returned if the local part fails validation following the guidelines in RFC3696. The +// domain part is optional and not validated. +func parseEmailAddress(address string) (local string, domain string, err error) { if address == "" { return "", "", fmt.Errorf("Empty address") } @@ -185,61 +265,9 @@ LOOP: if inStringQuote { return "", "", fmt.Errorf("Cannot end address with unterminated string quote") } - if !ValidateDomainPart(domain) { - return "", "", fmt.Errorf("Domain part validation failed") - } return buf.String(), domain, nil } -// ValidateDomainPart returns true if the domain part complies to RFC3696, RFC1035. Used by -// ParseEmailAddress(). -func ValidateDomainPart(domain string) bool { - if len(domain) == 0 { - return false - } - if len(domain) > 255 { - return false - } - if domain[len(domain)-1] != '.' { - domain += "." - } - prev := '.' - labelLen := 0 - hasAlphaNum := false - for _, c := range domain { - switch { - case ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z') || - ('0' <= c && c <= '9') || c == '_': - // Must contain some of these to be a valid label. - hasAlphaNum = true - labelLen++ - case c == '-': - if prev == '.' { - // Cannot lead with hyphen. - return false - } - case c == '.': - if prev == '.' || prev == '-' { - // Cannot end with hyphen or double-dot. - return false - } - if labelLen > 63 { - return false - } - if !hasAlphaNum { - return false - } - labelLen = 0 - hasAlphaNum = false - default: - // Unknown character. - return false - } - prev = c - } - return true -} - // ParseMailboxName takes a localPart string (ex: "user+ext" without "@domain") // and returns just the mailbox name (ex: "user"). Returns an error if // localPart contains invalid characters; it won't accept any that must be diff --git a/pkg/policy/address_test.go b/pkg/policy/address_test.go index 63e5090..82d417d 100644 --- a/pkg/policy/address_test.go +++ b/pkg/policy/address_test.go @@ -122,51 +122,170 @@ func TestShouldStoreDomain(t *testing.T) { } } -func TestExtractMailbox(t *testing.T) { - addrPolicy := policy.Addressing{Config: &config.Root{}} +func TestExtractMailboxValid(t *testing.T) { + localPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: "local"}} + fullPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: "full"}} - var validTable = []struct { - input string - expect string + testTable := []struct { + input string // Input to test + local string // Expected output when mailbox naming = local + full string // Expected output when mailbox naming = full }{ - {"mailbox", "mailbox"}, - {"user123", "user123"}, - {"MailBOX", "mailbox"}, - {"First.Last", "first.last"}, - {"user+label", "user"}, - {"chars!#$%", "chars!#$%"}, - {"chars&'*-", "chars&'*-"}, - {"chars=/?^", "chars=/?^"}, - {"chars_`.{", "chars_`.{"}, - {"chars|}~", "chars|}~"}, + { + input: "mailbox", + local: "mailbox", + full: "mailbox", + }, + { + input: "user123", + local: "user123", + full: "user123", + }, + { + input: "MailBOX", + local: "mailbox", + full: "mailbox", + }, + { + input: "First.Last", + local: "first.last", + full: "first.last", + }, + { + input: "user+label", + local: "user", + full: "user", + }, + { + input: "chars!#$%", + local: "chars!#$%", + full: "chars!#$%", + }, + { + input: "chars&'*-", + local: "chars&'*-", + full: "chars&'*-", + }, + { + input: "chars=/?^", + local: "chars=/?^", + full: "chars=/?^", + }, + { + input: "chars_`.{", + local: "chars_`.{", + full: "chars_`.{", + }, + { + input: "chars|}~", + local: "chars|}~", + full: "chars|}~", + }, + { + input: "mailbox@domain.com", + local: "mailbox", + full: "mailbox@domain.com", + }, + { + input: "user123@domain.com", + local: "user123", + full: "user123@domain.com", + }, + { + input: "MailBOX@domain.com", + local: "mailbox", + full: "mailbox@domain.com", + }, + { + input: "First.Last@domain.com", + local: "first.last", + full: "first.last@domain.com", + }, + { + input: "user+label@domain.com", + local: "user", + full: "user@domain.com", + }, + { + input: "chars!#$%@domain.com", + local: "chars!#$%", + full: "chars!#$%@domain.com", + }, + { + input: "chars&'*-@domain.com", + local: "chars&'*-", + full: "chars&'*-@domain.com", + }, + { + input: "chars=/?^@domain.com", + local: "chars=/?^", + full: "chars=/?^@domain.com", + }, + { + input: "chars_`.{@domain.com", + local: "chars_`.{", + full: "chars_`.{@domain.com", + }, + { + input: "chars|}~@domain.com", + local: "chars|}~", + full: "chars|}~@domain.com", + }, } - for _, tt := range validTable { - if result, err := addrPolicy.ExtractMailbox(tt.input); err != nil { - t.Errorf("Error while parsing %q: %v", tt.input, err) + for _, tc := range testTable { + if result, err := localPolicy.ExtractMailbox(tc.input); err != nil { + t.Errorf("Error while parsing with local naming %q: %v", tc.input, err) } else { - if result != tt.expect { - t.Errorf("Parsing %q, expected %q, got %q", tt.input, tt.expect, result) + if result != tc.local { + t.Errorf("Parsing %q, expected %q, got %q", tc.input, tc.local, result) + } + } + if result, err := fullPolicy.ExtractMailbox(tc.input); err != nil { + t.Errorf("Error while parsing with full naming %q: %v", tc.input, err) + } else { + if result != tc.full { + t.Errorf("Parsing %q, expected %q, got %q", tc.input, tc.full, result) } } } - var invalidTable = []struct { +} + +func TestExtractMailboxInvalid(t *testing.T) { + localPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: "local"}} + fullPolicy := policy.Addressing{Config: &config.Root{MailboxNaming: "full"}} + // Test local mailbox naming policy. + localInvalidTable := []struct { input, msg string }{ {"", "Empty mailbox name is not permitted"}, - {"user@host", "@ symbol not permitted"}, {"first last", "Space not permitted"}, {"first\"last", "Double quote not permitted"}, {"first\nlast", "Control chars not permitted"}, } - for _, tt := range invalidTable { - if _, err := addrPolicy.ExtractMailbox(tt.input); err == nil { - t.Errorf("Didn't get an error while parsing %q: %v", tt.input, tt.msg) + for _, tt := range localInvalidTable { + if _, err := localPolicy.ExtractMailbox(tt.input); err == nil { + t.Errorf("Didn't get an error while parsing in local mode %q: %v", tt.input, tt.msg) + } + } + // Test full mailbox naming policy. + fullInvalidTable := []struct { + input, msg string + }{ + {"", "Empty mailbox name is not permitted"}, + {"user@host@domain.com", "@ symbol not permitted"}, + {"first last@domain.com", "Space not permitted"}, + {"first\"last@domain.com", "Double quote not permitted"}, + {"first\nlast@domain.com", "Control chars not permitted"}, + } + for _, tt := range fullInvalidTable { + if _, err := fullPolicy.ExtractMailbox(tt.input); err == nil { + t.Errorf("Didn't get an error while parsing in full mode %q: %v", tt.input, tt.msg) } } } func TestValidateDomain(t *testing.T) { - var testTable = []struct { + testTable := []struct { input string expect bool msg string @@ -197,7 +316,7 @@ func TestValidateDomain(t *testing.T) { } func TestValidateLocal(t *testing.T) { - var testTable = []struct { + testTable := []struct { input string expect bool msg string @@ -213,12 +332,12 @@ func TestValidateLocal(t *testing.T) { {"first..last", false, "Sequence of periods is not allowed"}, {".user", false, "Cannot lead with a period"}, {"user.", false, "Cannot end with a period"}, - {"james@mail", false, "Unquoted @ not permitted"}, + // {"james@mail", false, "Unquoted @ not permitted"}, {"first last", false, "Unquoted space not permitted"}, {"tricky\\. ", false, "Unquoted space not permitted"}, {"no,commas", false, "Unquoted comma not allowed"}, {"t[es]t", false, "Unquoted square brackets not allowed"}, - {"james\\", false, "Cannot end with backslash quote"}, + // {"james\\", false, "Cannot end with backslash quote"}, {"james\\@mail", true, "Quoted @ permitted"}, {"quoted\\ space", true, "Quoted space permitted"}, {"no\\,commas", true, "Quoted comma is OK"}, diff --git a/pkg/rest/apiv1_controller_test.go b/pkg/rest/apiv1_controller_test.go index cf64234..885ef3c 100644 --- a/pkg/rest/apiv1_controller_test.go +++ b/pkg/rest/apiv1_controller_test.go @@ -37,7 +37,7 @@ func TestRestMailboxList(t *testing.T) { logbuf := setupWebServer(mm) // Test invalid mailbox name - w, err := testRestGet(baseURL + "/mailbox/foo@bar") + w, err := testRestGet(baseURL + "/mailbox/foo%20bar") expectCode := 500 if err != nil { t.Fatal(err) @@ -139,7 +139,7 @@ func TestRestMessage(t *testing.T) { logbuf := setupWebServer(mm) // Test invalid mailbox name - w, err := testRestGet(baseURL + "/mailbox/foo@bar/0001") + w, err := testRestGet(baseURL + "/mailbox/foo%20bar/0001") expectCode := 500 if err != nil { t.Fatal(err) diff --git a/pkg/server/smtp/handler.go b/pkg/server/smtp/handler.go index 706670c..ea82c81 100644 --- a/pkg/server/smtp/handler.go +++ b/pkg/server/smtp/handler.go @@ -16,10 +16,13 @@ import ( "github.com/rs/zerolog/log" ) -// State tracks the current mode of our SMTP state machine +// State tracks the current mode of our SMTP state machine. type State int const ( + // timeStampFormat to use in Received header. + timeStampFormat = "Mon, 02 Jan 2006 15:04:05 -0700 (MST)" + // GREET State: Waiting for HELO GREET State = iota // READY State: Got HELO, waiting for MAIL @@ -32,7 +35,11 @@ const ( QUIT ) -const timeStampFormat = "Mon, 02 Jan 2006 15:04:05 -0700 (MST)" +// fromRegex captures the from address and optional BODY=8BITMIME clause. Matches FROM, while +// accepting '>' as quoted pair and in double quoted strings (?i) makes the regex case insensitive, +// (?:) is non-grouping sub-match +var fromRegex = regexp.MustCompile( + "(?i)^FROM:\\s*<((?:\\\\>|[^>])+|\"[^\"]+\"@[^>]+)>( [\\w= ]+)?$") func (s State) String() string { switch s { @@ -265,10 +272,8 @@ func parseHelloArgument(arg string) (string, error) { // READY state -> waiting for MAIL func (s *Session) readyHandler(cmd string, arg string) { if cmd == "MAIL" { - // Match FROM, while accepting '>' as quoted pair and in double quoted strings - // (?i) makes the regex case insensitive, (?:) is non-grouping sub-match - re := regexp.MustCompile("(?i)^FROM:\\s*<((?:\\\\>|[^>])+|\"[^\"]+\"@[^>]+)>( [\\w= ]+)?$") - m := re.FindStringSubmatch(arg) + // Capture group 1: from address. 2: optional params. + m := fromRegex.FindStringSubmatch(arg) if m == nil { s.send("501 Was expecting MAIL arg syntax of FROM:
") s.logger.Warn().Msgf("Bad MAIL argument: %q", arg)