diff --git a/README.md b/README.md index 06177c5..398ef7d 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,7 @@ This package currently provides the following checkers: - [ascii](doc/checkers/ascii.md) checks if the given string consists of only ASCII characters. - [cidr](doc/checkers/cidr.md) checker checks if the value is a valid CIDR notation IP address and prefix length. - [digits](doc/checkers/digits.md) checks if the given string consists of only digit characters. +- [email](doc/checkers/email.md) checks if the given string is an email address. - [fqdn](doc/checkers/fqdn.md) checks if the given string is a fully qualified domain name. - [ip](doc/checkers/ip.md) checks if the given value is an IP address. - [ipv4](doc/checkers/ipv4.md) checks if the given value is an IPv4 address. diff --git a/checker.go b/checker.go index 2cedfcd..1ee173e 100644 --- a/checker.go +++ b/checker.go @@ -35,6 +35,7 @@ var makers = map[string]MakeFunc{ CheckerAscii: makeAscii, CheckerCidr: makeCidr, CheckerDigits: makeDigits, + CheckerEmail: makeEmail, CheckerFqdn: makeFqdn, CheckerIp: makeIp, CheckerIpV4: makeIpV4, diff --git a/doc/checkers/email.md b/doc/checkers/email.md new file mode 100644 index 0000000..e27f7cd --- /dev/null +++ b/doc/checkers/email.md @@ -0,0 +1,28 @@ +# Email Checker + +The ```email``` checker checks if the given string is an email address. If the given string is not a valid email address, the checker will return the ```NOT_EMAIL``` result. Here is an example: + +```golang +type User struct { + Email string `checkers:"email"` +} + +user := &User{ + Email: "user@zdo.com", +} + +_, valid := Check(user) +if !valid { + // Send the mistakes back to the user +} +``` + +In your custom checkers, you can call the ```ascii``` checker function ```IsAscii``` to validate the user input. Here is an example: + +```golang +result := IsEmail("user@zdo.com") + +if result != ResultValid { + // Send the mistakes back to the user +} +``` diff --git a/email.go b/email.go new file mode 100644 index 0000000..8797661 --- /dev/null +++ b/email.go @@ -0,0 +1,114 @@ +package checker + +import ( + "reflect" + "regexp" + "strings" +) + +// CheckerEmail is the name of the checker. +const CheckerEmail = "email" + +// ResultNotFqdn indicates that the given string is not a valid email. +const ResultNotEmail = "NOT_EMAIL" + +// ipV6Prefix is the IPv6 prefix for the domain. +const ipV6Prefix = "[IPv6:" + +// notQuotedChars is the valid not quoted characters. +var notQuotedChars = regexp.MustCompile("[a-zA-Z0-9!#$%&'*+-/=?^_`{|}~]") + +// IsEmail checks if the given string is an email address. +func IsEmail(email string) Result { + atIndex := strings.LastIndex(email, "@") + if atIndex == -1 || atIndex == len(email)-1 { + return ResultNotEmail + } + + domain := email[atIndex+1:] + if isValidEmailDomain(domain) != ResultValid { + return ResultNotEmail + } + + user := email[:atIndex] + + // Cannot be empty user + if len(user) == 0 || len(user) > 64 { + return ResultNotEmail + } + + // Cannot start or end with dot + if user[0] == '.' || user[len(user)-1] == '.' { + return ResultNotEmail + } + + quoted := false + start := true + prev := ' ' + + for _, c := range user { + // Cannot have a double dot unless quoted + if !quoted && c == '.' && prev == '.' { + return ResultNotEmail + } + + if start { + start = false + + if c == '"' { + quoted = true + prev = c + continue + } + } + + if !quoted { + if c == '.' { + start = true + } else if !notQuotedChars.MatchString(string(c)) { + return ResultNotEmail + } + } else { + if c == '"' && prev != '\\' { + quoted = false + } + } + + prev = c + } + + return ResultValid +} + +// makeEmail makes a checker function for the email checker. +func makeEmail(_ string) CheckFunc { + return checkEmail +} + +// checkEmail checks if the given string is an email address. +func checkEmail(value, _ reflect.Value) Result { + if value.Kind() != reflect.String { + panic("string expected") + } + + return IsEmail(value.String()) +} + +// isValidEmailDomain checks if the email domain is a IPv4 or IPv6 address, or a FQDN. +func isValidEmailDomain(domain string) Result { + if len(domain) > 255 { + return ResultNotEmail + } + + if domain[0] == '[' { + if strings.HasPrefix(domain, ipV6Prefix) { + // postmaster@[IPv6:2001:0db8:85a3:0000:0000:8a2e:0370:7334] + return IsIpV6(domain[len(ipV6Prefix) : len(domain)-1]) + } + + // postmaster@[123.123.123.123] + return IsIpV4(domain[1 : len(domain)-1]) + } + + return IsFqdn(domain) +} diff --git a/email_test.go b/email_test.go new file mode 100644 index 0000000..e88530d --- /dev/null +++ b/email_test.go @@ -0,0 +1,83 @@ +package checker + +import "testing" + +func TestCheckEmailNonString(t *testing.T) { + defer FailIfNoPanic(t) + + type User struct { + Email int `checkers:"email"` + } + + user := &User{} + + Check(user) +} + +func TestCheckEmailValid(t *testing.T) { + type User struct { + Email string `checkers:"email"` + } + + user := &User{ + Email: "user@zdo.com", + } + + _, valid := Check(user) + if !valid { + t.Fail() + } +} + +func TestIsEmailValid(t *testing.T) { + validEmails := []string{ + "simple@example.com", + "very.common@example.com", + "disposable.style.email.with+symbol@example.com", + "other.email-with-hyphen@and.subdomains.example.com", + "fully-qualified-domain@example.com", + "user.name+tag+sorting@example.com", + "x@example.com", + "example-indeed@strange-example.com", + "test/test@test.com", + "example@s.example", + "\" \"@example.org", + "\"john..doe\"@example.org", + "mailhost!username@example.org", + "\"very.(),:;<>[]\\\".VERY.\\\"very@\\\\ \\\"very\\\".unusual\"@strange.example.com", + "user%example.com@example.org", + "user-@example.org", + "postmaster@[123.123.123.123]", + "postmaster@[IPv6:2001:0db8:85a3:0000:0000:8a2e:0370:7334]", + } + + for _, email := range validEmails { + if IsEmail(email) != ResultValid { + t.Fatal(email) + } + } +} + +func TestIsEmailInvalid(t *testing.T) { + validEmails := []string{ + "Abc.example.com", + "A@b@c@example.com", + "a\"b(c)d,e:f;gi[j\\k]l@example.com", + "just\"not\"right@example.com", + "this is\"not\\allowed@example.com", + "this\\ still\\\"not\\\\allowed@example.com", + "1234567890123456789012345678901234567890123456789012345678901234+x@example.com", + "i_like_underscore@but_its_not_allowed_in_this_part.example.com", + "QA[icon]CHOCOLATE[icon]@test.com", + ".cannot.start.with.dot@example.com", + "cannot.end.with.dot.@example.com", + "cannot.have..double.dots@example.com", + "user@domaincannotbemorethan255charactersdomaincannotbemorethan255charactersdomaincannotbemorethan255charactersdomaincannotbemorethan255charactersdomaincannotbemorethan255charactersdomaincannotbemorethan255charactersdomaincannotbemorethan255charactersdomaincannotbemorethan255characters.com", + } + + for _, email := range validEmails { + if IsEmail(email) == ResultValid { + t.Fatal(email) + } + } +} diff --git a/fqdn.go b/fqdn.go index 6617c22..1112dc0 100644 --- a/fqdn.go +++ b/fqdn.go @@ -9,7 +9,7 @@ import ( // CheckerFqdn is the name of the checker. const CheckerFqdn = "fqdn" -// ResultNotFqdn indicates that the given string contains non-ASCII characters. +// ResultNotFqdn indicates that the given string is not a valid FQDN. const ResultNotFqdn = "NOT_FQDN" // Valid characters excluding full-width characters. @@ -56,7 +56,7 @@ func IsFqdn(domain string) Result { return ResultValid } -// makeFqdn makes a checker function for the ascii checker. +// makeFqdn makes a checker function for the fqdn checker. func makeFqdn(_ string) CheckFunc { return checkFqdn }