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 }