299 lines
7.8 KiB
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
|
|
}
|