chatdesk-ui/auth_v2.169.0/internal/mailer/validate.go

299 lines
7.8 KiB
Go

package mailer
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net"
"net/http"
"net/mail"
"strings"
"time"
"github.com/supabase/auth/internal/conf"
"golang.org/x/sync/errgroup"
)
var invalidEmailMap = map[string]bool{
// People type these often enough to be special cased.
"test@gmail.com": true,
"example@gmail.com": true,
"someone@gmail.com": true,
"test@email.com": true,
}
var invalidHostSuffixes = []string{
// These are a directly from Section 2 of RFC2606[1].
//
// [1] https://www.rfc-editor.org/rfc/rfc2606.html#section-2
".test",
".example",
".invalid",
".local",
".localhost",
}
var invalidHostMap = map[string]bool{
// These exist here too for when they are typed as "test@test"
"test": true,
"example": true,
"invalid": true,
"local": true,
"localhost": true,
// These are commonly typed and have DNS records which cause a
// large enough volume of bounce backs to special case.
"test.com": true,
"example.com": true,
"example.net": true,
"example.org": true,
// Hundreds of typos per day for this.
"gamil.com": true,
// These are not email providers, but people often use them.
"anonymous.com": true,
"email.com": true,
}
const (
validateEmailTimeout = 3 * time.Second
)
var (
// We use the default resolver for this.
validateEmailResolver net.Resolver
)
var (
ErrInvalidEmailAddress = errors.New("invalid_email_address")
ErrInvalidEmailFormat = errors.New("invalid_email_format")
ErrInvalidEmailDNS = errors.New("invalid_email_dns")
)
type EmailValidator struct {
extended bool
serviceURL string
serviceHeaders map[string][]string
}
func newEmailValidator(mc conf.MailerConfiguration) *EmailValidator {
return &EmailValidator{
extended: mc.EmailValidationExtended,
serviceURL: mc.EmailValidationServiceURL,
serviceHeaders: mc.GetEmailValidationServiceHeaders(),
}
}
func (ev *EmailValidator) isExtendedEnabled() bool { return ev.extended }
func (ev *EmailValidator) isServiceEnabled() bool { return ev.serviceURL != "" }
// Validate performs validation on the given email.
//
// When extended is true, 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
//
// When serviceURL AND serviceKey are non-empty strings it uses the remote
// service to determine if the email is valid.
func (ev *EmailValidator) Validate(ctx context.Context, email string) error {
if !ev.isExtendedEnabled() && !ev.isServiceEnabled() {
return nil
}
// One of the two validation methods are enabled, set a timeout.
ctx, cancel := context.WithTimeout(ctx, validateEmailTimeout)
defer cancel()
// Easier control flow here to always use errgroup, it has very little
// overhad in comparison to the network calls it makes. The reason
// we run both checks concurrently is to tighten the timeout without
// potentially missing a call to the validation service due to a
// dns timeout or something more nefarious like a honeypot dns entry.
g := new(errgroup.Group)
// Validate the static rules first to prevent round trips on bad emails
// and to parse the host ahead of time.
if ev.isExtendedEnabled() {
// First validate static checks such as format, known invalid hosts
// and any other network free checks. Running this check before we
// call the service will help reduce the number of calls with known
// invalid emails.
host, err := ev.validateStatic(email)
if err != nil {
return err
}
// Start the goroutine to validate the host.
g.Go(func() error { return ev.validateHost(ctx, host) })
}
// If the service check is enabled we start a goroutine to run
// that check as well.
if ev.isServiceEnabled() {
g.Go(func() error { return ev.validateService(ctx, email) })
}
return g.Wait()
}
// validateStatic will validate the format and do the static checks before
// returning the host portion of the email.
func (ev *EmailValidator) validateStatic(email string) (string, error) {
if !ev.isExtendedEnabled() {
return "", nil
}
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 "", ErrInvalidEmailDNS
}
for i := range invalidHostSuffixes {
if strings.HasSuffix(host, invalidHostSuffixes[i]) {
return "", ErrInvalidEmailDNS
}
}
name := email[:i]
if err := ev.validateProviders(name, host); err != nil {
return "", err
}
return host, nil
}
func (ev *EmailValidator) validateService(ctx context.Context, email string) error {
if !ev.isServiceEnabled() {
return nil
}
reqObject := struct {
EmailAddress string `json:"email"`
}{email}
reqData, err := json.Marshal(&reqObject)
if err != nil {
return nil
}
rdr := bytes.NewReader(reqData)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, ev.serviceURL, rdr)
if err != nil {
return nil
}
req.Header.Set("Content-Type", "application/json")
for name, vals := range ev.serviceHeaders {
for _, val := range vals {
req.Header.Set(name, val)
}
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil
}
defer res.Body.Close()
resObject := struct {
Valid *bool `json:"valid"`
}{}
if res.StatusCode/100 != 2 {
// we ignore the error here just in case the service is down
return nil
}
dec := json.NewDecoder(io.LimitReader(res.Body, 1<<5))
if err := dec.Decode(&resObject); err != nil {
return nil
}
// If the object did not contain a valid key we consider the check as
// failed. We _must_ get a valid JSON response with a "valid" field.
if resObject.Valid == nil || *resObject.Valid {
return nil
}
return ErrInvalidEmailAddress
}
func (ev *EmailValidator) validateProviders(name, host string) error {
switch host {
case "gmail.com":
// Based on a sample of internal data, this reduces the number of
// bounced emails by 23%. Gmail documentation specifies that the
// min user name length is 6 characters. There may be some accounts
// from early gmail beta with shorter email addresses, but I think
// this reduces bounce rates enough to be worth adding for now.
if len(name) < 6 {
return ErrInvalidEmailAddress
}
}
return nil
}
func (ev *EmailValidator) validateHost(ctx context.Context, host string) error {
_, 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 ErrInvalidEmailDNS
}
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].
//
// See section 2 and 3 of: https://www.rfc-editor.org/rfc/rfc2606
// [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.
if !dnsError.IsTemporary && !dnsError.IsTimeout && dnsError.IsNotFound {
return true
}
return false
}