From a992065d6cd970630b86e6dd81b0cd11af6e9454 Mon Sep 17 00:00:00 2001 From: Aneurin Barker Snook Date: Mon, 7 Sep 2026 13:21:17 +0100 Subject: [PATCH] email: validate with net/mail.ParseAddress Replace the RFC 5322 mega-regexp with net/mail.ParseAddress, which is the standard library's address parser and far easier to audit. A bare address is required: inputs with a display name, angle brackets, a comment, or trailing content are rejected, as is an address list. Behaviour change: ParseAddress does not require the domain to contain a dot, so "alice@localhost" now validates. Layer Match or a DNS lookup on top if a stricter domain is needed. Also rewrites FuzzEmail, which previously asserted that *every* input is invalid, into checks that Email never returns an unexpected error type, never panics, and gives a stable verdict. Co-Authored-By: Claude Sonnet 5 --- email.go | 16 ++++++++++------ email_test.go | 32 +++++++++++++++++++++++++------- 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/email.go b/email.go index 3ddc8c0..d403eda 100644 --- a/email.go +++ b/email.go @@ -1,19 +1,23 @@ package validate import ( - "regexp" + "net/mail" ) var ( ErrInvalidEmail = NewError("invalid email address") ) -// Based on https://stackoverflow.com/a/201378 -var emailRegexp = regexp.MustCompile("^(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|\"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*\")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\\])$") - -// Email validates an email address. +// Email validates an email address using net/mail.ParseAddress. +// +// Only a bare address is accepted (alice@example.com). Anything with a +// display name, angle brackets, a comment, or trailing content is rejected, +// as is a list of addresses. Note that ParseAddress does not require the +// domain to have a dot, so "alice@localhost" is considered valid; layer on +// Match or a DNS check if you need to be stricter. func Email(value string) error { - if !emailRegexp.MatchString(value) { + addr, err := mail.ParseAddress(value) + if err != nil || addr.Name != "" || addr.Address != value { return ErrInvalidEmail } diff --git a/email_test.go b/email_test.go index f58e68a..51fe065 100644 --- a/email_test.go +++ b/email_test.go @@ -12,14 +12,26 @@ func ExampleEmail() { } func FuzzEmail(f *testing.F) { - want := ErrInvalidEmail + for _, seed := range []string{ + "", "alice@example.com", "not an email", "a@b", + "Alice ", "alice@example.com ", + } { + f.Add(seed) + } f.Fuzz(func(t *testing.T, input string) { - got := Email(input) + err := Email(input) - if !errors.Is(got, want) { - t.Error("got", got) - t.Error("want", want) + // The only error Email ever returns is ErrInvalidEmail. + if err != nil && !errors.Is(err, ErrInvalidEmail) { + t.Fatalf("unexpected error for %q: %v", input, err) + } + + // A verdict of "valid" must be stable on re-validation. + if err == nil { + if err2 := Email(input); err2 != nil { + t.Fatalf("inconsistent verdict for %q: first nil, then %v", input, err2) + } } }) } @@ -28,9 +40,15 @@ func TestEmail(t *testing.T) { testCases := map[string]error{ "test@example.com": nil, "firstname.lastname@some-website.co.uk": nil, + "alice+tag@example.com": nil, + "alice@localhost": nil, // ParseAddress does not require a dotted domain - "not an email": ErrInvalidEmail, - "testexample.com": ErrInvalidEmail, + "not an email": ErrInvalidEmail, + "testexample.com": ErrInvalidEmail, + "Alice ": ErrInvalidEmail, // display name + "": ErrInvalidEmail, // angle brackets + "alice@example.com ": ErrInvalidEmail, // trailing space + "a@b.com, c@d.com": ErrInvalidEmail, // address list } for input, want := range testCases {