From 4aa04b4ea0cc329a02f7a51420656e3c4a28af14 Mon Sep 17 00:00:00 2001 From: Evan Jarrett Date: Mon, 1 Dec 2025 09:50:53 -0600 Subject: [PATCH] appview/email: Replace regex check with mail.ParseAddress. Add unit tests. Signed-off-by: Evan Jarrett --- appview/email/email.go | 19 +++++-------- appview/email/email_test.go | 53 +++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 12 deletions(-) create mode 100644 appview/email/email_test.go diff --git a/appview/email/email.go b/appview/email/email.go index 40f6920c..872fb5b1 100644 --- a/appview/email/email.go +++ b/appview/email/email.go @@ -3,7 +3,7 @@ package email import ( "fmt" "net" - "regexp" + "net/mail" "strings" "github.com/resend/resend-go/v2" @@ -34,24 +34,19 @@ func SendEmail(email Email) error { } func IsValidEmail(email string) bool { - // Basic length check - if len(email) < 3 || len(email) > 254 { + // Reject whitespace (ParseAddress normalizes it away) + if strings.ContainsAny(email, " \t\n\r") { return false } - // Regular expression for email validation (RFC 5322 compliant) - pattern := `^[a-zA-Z0-9.!#$%&'*+/=?^_\x60{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$` - - // Compile regex - regex := regexp.MustCompile(pattern) - - // Check if email matches regex pattern - if !regex.MatchString(email) { + // Use stdlib RFC 5322 parser + addr, err := mail.ParseAddress(email) + if err != nil { return false } // Split email into local and domain parts - parts := strings.Split(email, "@") + parts := strings.Split(addr.Address, "@") domain := parts[1] mx, err := net.LookupMX(domain) diff --git a/appview/email/email_test.go b/appview/email/email_test.go new file mode 100644 index 00000000..9d7f4edb --- /dev/null +++ b/appview/email/email_test.go @@ -0,0 +1,53 @@ +package email + +import ( + "testing" +) + +func TestIsValidEmail(t *testing.T) { + tests := []struct { + name string + email string + want bool + }{ + // Valid emails using RFC 2606 reserved domains + {"standard email", "user@example.com", true}, + {"single char local", "a@example.com", true}, + {"dot in middle", "first.last@example.com", true}, + {"multiple dots", "a.b.c@example.com", true}, + {"plus tag", "user+tag@example.com", true}, + {"numbers", "user123@example.com", true}, + {"example.org", "user@example.org", true}, + {"example.net", "user@example.net", true}, + + // Invalid format - rejected by mail.ParseAddress + {"empty string", "", false}, + {"no at sign", "userexample.com", false}, + {"no domain", "user@", false}, + {"no local part", "@example.com", false}, + {"double at", "user@@example.com", false}, + {"just at sign", "@", false}, + {"leading dot", ".user@example.com", false}, + {"trailing dot", "user.@example.com", false}, + {"consecutive dots", "user..name@example.com", false}, + + // Whitespace - rejected before parsing + {"space in local", "user @example.com", false}, + {"space in domain", "user@ example.com", false}, + {"tab", "user\t@example.com", false}, + {"newline", "user\n@example.com", false}, + + // MX lookup - using RFC 2606 reserved TLDs (guaranteed no MX) + {"invalid TLD", "user@example.invalid", false}, + {"test TLD", "user@mail.test", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := IsValidEmail(tt.email) + if got != tt.want { + t.Errorf("IsValidEmail(%q) = %v, want %v", tt.email, got, tt.want) + } + }) + } +} -- 2.43.0