appview/email: Replace regex check with mail.ParseAddress. Add unit tests. #839

merged
opened by evan.jarrett.net targeting master from evan.jarrett.net/core: email-verify

Was troubleshooting an issue in discord and wrote some tests to verify things were parsed correctly. The orignal regex was missing leading and trailing period detection. Go has a built in function for parsing/validating email addresses based on the RFC that doesn't use regex, and performs slightly faster in a benchmark.

Changed files
+60 -12
appview
+7 -12
appview/email/email.go
···
import (
"fmt"
"net"
-
"regexp"
"strings"
"github.com/resend/resend-go/v2"
···
}
func IsValidEmail(email string) bool {
-
// Basic length check
-
if len(email) < 3 || len(email) > 254 {
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) {
return false
}
// Split email into local and domain parts
-
parts := strings.Split(email, "@")
domain := parts[1]
mx, err := net.LookupMX(domain)
···
import (
"fmt"
"net"
+
"net/mail"
"strings"
"github.com/resend/resend-go/v2"
···
}
func IsValidEmail(email string) bool {
+
// Reject whitespace (ParseAddress normalizes it away)
+
if strings.ContainsAny(email, " \t\n\r") {
return false
}
+
// 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(addr.Address, "@")
domain := parts[1]
mx, err := net.LookupMX(domain)
+53
appview/email/email_test.go
···
···
+
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)
+
}
+
})
+
}
+
}