Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add email validation function to lower bounce rates #1845

Merged
merged 6 commits into from
Dec 5, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions internal/mailer/validate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package mailer

import (
"context"
"errors"
"net"
"net/mail"
"strings"
"time"
)

var invalidEmailMap = map[string]bool{
"test@gmail.com": true,
"test@test.com": true,
"test@email.com": true,
}

// https://www.rfc-editor.org/rfc/rfc2606
var invalidHostMap = map[string]bool{
"test": true,
"example": true,
"invalid": true,
"local": true,
"localhost": true,
"example.com": true,
"example.net": true,
"example.org": true,
}

const (
validateEmailTimeout = 250 * time.Millisecond
)

var (
// We use the default resolver for this.
validateEmailResolver net.Resolver
)

var (
ErrInvalidEmailFormat = errors.New("invalid email format")
ErrInvalidEmailAddress = errors.New("invalid email address")
)

// ValidateEmail returns a nil error in all cases but the following:
// - `email` cannot be parsed by mail.ParseAddress
// - `email` has a domain with no DNS configured
func ValidateEmail(ctx context.Context, email string) error {
ctx, cancel := context.WithTimeout(ctx, validateEmailTimeout)
defer cancel()

return validateEmail(ctx, email)
}

func validateEmail(ctx context.Context, email string) error {
ea, err := mail.ParseAddress(email)
if err != nil {
return ErrInvalidEmailFormat
}

i := strings.LastIndex(ea.Address, "@")
if i == -1 {
return ErrInvalidEmailFormat
}

// few static lookups that are typed constantly and known to be invalid.
if invalidEmailMap[email] {
return ErrInvalidEmailAddress
}

host := email[i+1:]
if invalidHostMap[host] {
return ErrInvalidEmailAddress
}

_, err = validateEmailResolver.LookupMX(ctx, host)
if !isHostNotFound(err) {
return nil
}

_, err = validateEmailResolver.LookupHost(ctx, host)
if !isHostNotFound(err) {
return nil
}

// No addrs or mx records were found
return ErrInvalidEmailAddress
}

func isHostNotFound(err error) bool {
if err == nil {
// We had no err, so we treat it as valid. We don't check the mx records
// because RFC 5321 specifies that if an empty list of MX's are returned
// the host should be treated as the MX[1].
//
// [1] https://www.rfc-editor.org/rfc/rfc5321.html#section-5.1
return false
}

// No names present, we will try to get a positive assertion that the
// domain is not configured to receive email.
var dnsError *net.DNSError
if !errors.As(err, &dnsError) {
// We will be unable to determine with absolute certainy the email was
// invalid so we will err on the side of caution and return nil.
return false
}

// The type of err is dnsError, inspect it to see if we can be certain
// the domain has no mx records currently. For this we require that
// the error was not temporary or a timeout. If those are both false
// we trust the value in IsNotFound.
//
// TODO(cstockton): I think that in this case, I need to then lookup the
// host to ensure I'm following the section above. I think that if the
// mx record list is empty Go will set IsNotFound here.
if !dnsError.IsTemporary && !dnsError.IsTimeout && dnsError.IsNotFound {
return true
}
return false
}
92 changes: 92 additions & 0 deletions internal/mailer/validate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package mailer

import (
"context"
"testing"
"time"

"github.com/stretchr/testify/require"
)

func TestValidateEmail(t *testing.T) {
ctx := context.Background()
ctx, cancel := context.WithTimeout(ctx, time.Second*60)
defer cancel()

cases := []struct {
email string
timeout time.Duration
err string
}{
// valid (has mx record)
{email: "a@supabase.io"},
{email: "support@supabase.io"},
{email: "chris.stockton@supabase.io"},

// bad format
{email: "", err: "invalid email format"},
{email: "io", err: "invalid email format"},
{email: "supabase.io", err: "invalid email format"},
{email: "@supabase.io", err: "invalid email format"},

// invalid: valid mx records, but invalid and often typed
// (invalidEmailMap)
{email: "test@test.com", err: "invalid email address"},
{email: "test@gmail.com", err: "invalid email address"},
{email: "test@email.com", err: "invalid email address"},

// invalid: valid mx records, but invalid and often typed
// (invalidHostMap)
{email: "a@example.com", err: "invalid email address"},
{email: "a@example.net", err: "invalid email address"},
{email: "a@example.org", err: "invalid email address"},

// invalid: no mx records
{email: "a@test", err: "invalid email address"},
{email: "test@local", err: "invalid email address"},
{email: "test@example", err: "invalid email address"},
{email: "test@invalid", err: "invalid email address"},

// valid but not actually valid and typed a lot
{email: "a@invalid", err: "invalid email address"},
{email: "test@invalid", err: "invalid email address"},

// various invalid emails
{email: "test@test.localhost", err: "invalid email address"},
{email: "test@invalid.example.com", err: "invalid email address"},
{email: "test@no.such.email.host.supabase.io", err: "invalid email address"},

// this low timeout should simulate a dns timeout, which should
// not be treated as an invalid email.
{email: "test@test.localhost", timeout: time.Millisecond},

// likewise for a valid email
{email: "support@supabase.io", timeout: time.Millisecond},
}
for idx, tc := range cases {
func(timeout time.Duration) {
if timeout == 0 {
timeout = validateEmailTimeout
}

ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()

now := time.Now()
err := validateEmail(ctx, tc.email)
dur := time.Since(now)
if max := timeout + (time.Millisecond * 50); max < dur {
t.Fatal("timeout was not respected")
}

t.Logf("tc #%v - email %v", idx, tc.email)
if tc.err != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tc.err)
return
}
require.NoError(t, err)

}(tc.timeout)
}
}
Loading