Skip to content

Commit 2c291f0

Browse files
cstocktonChris Stockton
and
Chris Stockton
authored
feat: add email validation function to lower bounce rates (#1845)
The goal is to only return an error when we have a very high confidence the email won't be deliverable. This is currently going to be added as a draft for the team to review. I haven't actually implemented any paths that call this or configuration around when it is activated. --------- Co-authored-by: Chris Stockton <[email protected]>
1 parent 1c7202f commit 2c291f0

File tree

10 files changed

+727
-56
lines changed

10 files changed

+727
-56
lines changed

internal/api/errorcodes.go

+1
Original file line numberDiff line numberDiff line change
@@ -91,4 +91,5 @@ const (
9191
//#nosec G101 -- Not a secret value.
9292
ErrorCodeInvalidCredentials ErrorCode = "invalid_credentials"
9393
ErrorCodeEmailAddressNotAuthorized ErrorCode = "email_address_not_authorized"
94+
ErrorCodeEmailAddressInvalid ErrorCode = "email_address_invalid"
9495
)

internal/api/mail.go

+21-8
Original file line numberDiff line numberDiff line change
@@ -601,7 +601,6 @@ func (a *API) checkEmailAddressAuthorization(email string) bool {
601601
}
602602

603603
func (a *API) sendEmail(r *http.Request, tx *storage.Connection, u *models.User, emailActionType, otp, otpNew, tokenHashWithPrefix string) error {
604-
mailer := a.Mailer()
605604
ctx := r.Context()
606605
config := a.config
607606
referrerURL := utilities.GetReferrer(r, config)
@@ -675,20 +674,34 @@ func (a *API) sendEmail(r *http.Request, tx *storage.Connection, u *models.User,
675674
return a.invokeHook(tx, r, &input, &output)
676675
}
677676

677+
mr := a.Mailer()
678+
var err error
678679
switch emailActionType {
679680
case mail.SignupVerification:
680-
return mailer.ConfirmationMail(r, u, otp, referrerURL, externalURL)
681+
err = mr.ConfirmationMail(r, u, otp, referrerURL, externalURL)
681682
case mail.MagicLinkVerification:
682-
return mailer.MagicLinkMail(r, u, otp, referrerURL, externalURL)
683+
err = mr.MagicLinkMail(r, u, otp, referrerURL, externalURL)
683684
case mail.ReauthenticationVerification:
684-
return mailer.ReauthenticateMail(r, u, otp)
685+
err = mr.ReauthenticateMail(r, u, otp)
685686
case mail.RecoveryVerification:
686-
return mailer.RecoveryMail(r, u, otp, referrerURL, externalURL)
687+
err = mr.RecoveryMail(r, u, otp, referrerURL, externalURL)
687688
case mail.InviteVerification:
688-
return mailer.InviteMail(r, u, otp, referrerURL, externalURL)
689+
err = mr.InviteMail(r, u, otp, referrerURL, externalURL)
689690
case mail.EmailChangeVerification:
690-
return mailer.EmailChangeMail(r, u, otpNew, otp, referrerURL, externalURL)
691+
err = mr.EmailChangeMail(r, u, otpNew, otp, referrerURL, externalURL)
692+
default:
693+
err = errors.New("invalid email action type")
694+
}
695+
696+
switch {
697+
case errors.Is(err, mail.ErrInvalidEmailAddress),
698+
errors.Is(err, mail.ErrInvalidEmailFormat),
699+
errors.Is(err, mail.ErrInvalidEmailDNS):
700+
return badRequestError(
701+
ErrorCodeEmailAddressInvalid,
702+
"Email address %q is invalid",
703+
u.GetEmail())
691704
default:
692-
return errors.New("invalid email action type")
705+
return err
693706
}
694707
}

internal/conf/configuration.go

+28
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,33 @@ type MailerConfiguration struct {
399399
OtpLength int `json:"otp_length" split_words:"true"`
400400

401401
ExternalHosts []string `json:"external_hosts" split_words:"true"`
402+
403+
// EXPERIMENTAL: May be removed in a future release.
404+
EmailValidationExtended bool `json:"email_validation_extended" split_words:"true" default:"false"`
405+
EmailValidationServiceURL string `json:"email_validation_service_url" split_words:"true"`
406+
EmailValidationServiceHeaders string `json:"email_validation_service_key" split_words:"true"`
407+
408+
serviceHeaders map[string][]string `json:"-"`
409+
}
410+
411+
func (c *MailerConfiguration) Validate() error {
412+
headers := make(map[string][]string)
413+
414+
if c.EmailValidationServiceHeaders != "" {
415+
err := json.Unmarshal([]byte(c.EmailValidationServiceHeaders), &headers)
416+
if err != nil {
417+
return fmt.Errorf("conf: SMTP headers not a map[string][]string format: %w", err)
418+
}
419+
}
420+
421+
if len(headers) > 0 {
422+
c.serviceHeaders = headers
423+
}
424+
return nil
425+
}
426+
427+
func (c *MailerConfiguration) GetEmailValidationServiceHeaders() map[string][]string {
428+
return c.serviceHeaders
402429
}
403430

404431
type PhoneProviderConfiguration struct {
@@ -1020,6 +1047,7 @@ func (c *GlobalConfiguration) Validate() error {
10201047
&c.Tracing,
10211048
&c.Metrics,
10221049
&c.SMTP,
1050+
&c.Mailer,
10231051
&c.SAML,
10241052
&c.Security,
10251053
&c.Sessions,

internal/conf/configuration_test.go

+7
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ func TestGlobal(t *testing.T) {
2525
os.Setenv("GOTRUE_HOOK_MFA_VERIFICATION_ATTEMPT_URI", "pg-functions://postgres/auth/count_failed_attempts")
2626
os.Setenv("GOTRUE_HOOK_SEND_SMS_SECRETS", "v1,whsec_aWxpa2VzdXBhYmFzZXZlcnltdWNoYW5kaWhvcGV5b3Vkb3Rvbw==")
2727
os.Setenv("GOTRUE_SMTP_HEADERS", `{"X-PM-Metadata-project-ref":["project_ref"],"X-SES-Message-Tags":["ses:feedback-id-a=project_ref,ses:feedback-id-b=$messageType"]}`)
28+
os.Setenv("GOTRUE_MAILER_EMAIL_VALIDATION_SERVICE_HEADERS", `{"apikey":["test"]}`)
2829
os.Setenv("GOTRUE_SMTP_LOGGING_ENABLED", "true")
2930
gc, err := LoadGlobal("")
3031
require.NoError(t, err)
@@ -34,6 +35,12 @@ func TestGlobal(t *testing.T) {
3435
assert.Equal(t, "X-Request-ID", gc.API.RequestIDHeader)
3536
assert.Equal(t, "pg-functions://postgres/auth/count_failed_attempts", gc.Hook.MFAVerificationAttempt.URI)
3637

38+
{
39+
hdrs := gc.Mailer.GetEmailValidationServiceHeaders()
40+
assert.Equal(t, 1, len(hdrs["apikey"]))
41+
assert.Equal(t, "test", hdrs["apikey"][0])
42+
}
43+
3744
}
3845

3946
func TestRateLimits(t *testing.T) {

internal/mailer/mailer.go

+13-11
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ type Mailer interface {
1818
MagicLinkMail(r *http.Request, user *models.User, otp, referrerURL string, externalURL *url.URL) error
1919
EmailChangeMail(r *http.Request, user *models.User, otpNew, otpCurrent, referrerURL string, externalURL *url.URL) error
2020
ReauthenticateMail(r *http.Request, user *models.User, otp string) error
21-
ValidateEmail(email string) error
2221
GetEmailActionLink(user *models.User, actionType, referrerURL string, externalURL *url.URL) (string, error)
2322
}
2423

@@ -46,18 +45,21 @@ func NewMailer(globalConfig *conf.GlobalConfiguration) Mailer {
4645
var mailClient MailClient
4746
if globalConfig.SMTP.Host == "" {
4847
logrus.Infof("Noop mail client being used for %v", globalConfig.SiteURL)
49-
mailClient = &noopMailClient{}
48+
mailClient = &noopMailClient{
49+
EmailValidator: newEmailValidator(globalConfig.Mailer),
50+
}
5051
} else {
5152
mailClient = &MailmeMailer{
52-
Host: globalConfig.SMTP.Host,
53-
Port: globalConfig.SMTP.Port,
54-
User: globalConfig.SMTP.User,
55-
Pass: globalConfig.SMTP.Pass,
56-
LocalName: u.Hostname(),
57-
From: from,
58-
BaseURL: globalConfig.SiteURL,
59-
Logger: logrus.StandardLogger(),
60-
MailLogging: globalConfig.SMTP.LoggingEnabled,
53+
Host: globalConfig.SMTP.Host,
54+
Port: globalConfig.SMTP.Port,
55+
User: globalConfig.SMTP.User,
56+
Pass: globalConfig.SMTP.Pass,
57+
LocalName: u.Hostname(),
58+
From: from,
59+
BaseURL: globalConfig.SiteURL,
60+
Logger: logrus.StandardLogger(),
61+
MailLogging: globalConfig.SMTP.LoggingEnabled,
62+
EmailValidator: newEmailValidator(globalConfig.Mailer),
6163
}
6264
}
6365

internal/mailer/mailme.go

+26-12
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package mailer
22

33
import (
44
"bytes"
5+
"context"
56
"errors"
67
"html/template"
78
"io"
@@ -24,22 +25,29 @@ const TemplateExpiration = 10 * time.Second
2425

2526
// MailmeMailer lets MailMe send templated mails
2627
type MailmeMailer struct {
27-
From string
28-
Host string
29-
Port int
30-
User string
31-
Pass string
32-
BaseURL string
33-
LocalName string
34-
FuncMap template.FuncMap
35-
cache *TemplateCache
36-
Logger logrus.FieldLogger
37-
MailLogging bool
28+
From string
29+
Host string
30+
Port int
31+
User string
32+
Pass string
33+
BaseURL string
34+
LocalName string
35+
FuncMap template.FuncMap
36+
cache *TemplateCache
37+
Logger logrus.FieldLogger
38+
MailLogging bool
39+
EmailValidator *EmailValidator
3840
}
3941

4042
// Mail sends a templated mail. It will try to load the template from a URL, and
4143
// otherwise fall back to the default
42-
func (m *MailmeMailer) Mail(to, subjectTemplate, templateURL, defaultTemplate string, templateData map[string]interface{}, headers map[string][]string, typ string) error {
44+
func (m *MailmeMailer) Mail(
45+
ctx context.Context,
46+
to, subjectTemplate, templateURL, defaultTemplate string,
47+
templateData map[string]interface{},
48+
headers map[string][]string,
49+
typ string,
50+
) error {
4351
if m.FuncMap == nil {
4452
m.FuncMap = map[string]interface{}{}
4553
}
@@ -51,6 +59,12 @@ func (m *MailmeMailer) Mail(to, subjectTemplate, templateURL, defaultTemplate st
5159
}
5260
}
5361

62+
if m.EmailValidator != nil {
63+
if err := m.EmailValidator.Validate(ctx, to); err != nil {
64+
return err
65+
}
66+
}
67+
5468
tmp, err := template.New("Subject").Funcs(template.FuncMap(m.FuncMap)).Parse(subjectTemplate)
5569
if err != nil {
5670
return err

internal/mailer/noop.go

+16-2
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,28 @@
11
package mailer
22

33
import (
4+
"context"
45
"errors"
56
)
67

7-
type noopMailClient struct{}
8+
type noopMailClient struct {
9+
EmailValidator *EmailValidator
10+
}
811

9-
func (m *noopMailClient) Mail(to, subjectTemplate, templateURL, defaultTemplate string, templateData map[string]interface{}, headers map[string][]string, typ string) error {
12+
func (m *noopMailClient) Mail(
13+
ctx context.Context,
14+
to, subjectTemplate, templateURL, defaultTemplate string,
15+
templateData map[string]interface{},
16+
headers map[string][]string,
17+
typ string,
18+
) error {
1019
if to == "" {
1120
return errors.New("to field cannot be empty")
1221
}
22+
if m.EmailValidator != nil {
23+
if err := m.EmailValidator.Validate(ctx, to); err != nil {
24+
return err
25+
}
26+
}
1327
return nil
1428
}

internal/mailer/template.go

+31-23
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,37 @@
11
package mailer
22

33
import (
4+
"context"
45
"fmt"
56
"net/http"
67
"net/url"
78
"strings"
89

9-
"github.com/badoux/checkmail"
1010
"github.com/supabase/auth/internal/conf"
1111
"github.com/supabase/auth/internal/models"
1212
)
1313

14+
type MailRequest struct {
15+
To string
16+
SubjectTemplate string
17+
TemplateURL string
18+
DefaultTemplate string
19+
TemplateData map[string]interface{}
20+
Headers map[string][]string
21+
Type string
22+
}
23+
1424
type MailClient interface {
15-
Mail(string, string, string, string, map[string]interface{}, map[string][]string, string) error
25+
Mail(
26+
ctx context.Context,
27+
to string,
28+
subjectTemplate string,
29+
templateURL string,
30+
defaultTemplate string,
31+
templateData map[string]interface{},
32+
headers map[string][]string,
33+
typ string,
34+
) error
1635
}
1736

1837
// TemplateMailer will send mail and use templates from the site for easy mail styling
@@ -81,12 +100,6 @@ const defaultReauthenticateMail = `<h2>Confirm reauthentication</h2>
81100
82101
<p>Enter the code: {{ .Token }}</p>`
83102

84-
// ValidateEmail returns nil if the email is valid,
85-
// otherwise an error indicating the reason it is invalid
86-
func (m TemplateMailer) ValidateEmail(email string) error {
87-
return checkmail.ValidateFormat(email)
88-
}
89-
90103
func (m *TemplateMailer) Headers(messageType string) map[string][]string {
91104
originalHeaders := m.Config.SMTP.NormalizedHeaders()
92105

@@ -145,6 +158,7 @@ func (m *TemplateMailer) InviteMail(r *http.Request, user *models.User, otp, ref
145158
}
146159

147160
return m.Mailer.Mail(
161+
r.Context(),
148162
user.GetEmail(),
149163
withDefault(m.Config.Mailer.Subjects.Invite, "You have been invited"),
150164
m.Config.Mailer.Templates.Invite,
@@ -177,6 +191,7 @@ func (m *TemplateMailer) ConfirmationMail(r *http.Request, user *models.User, ot
177191
}
178192

179193
return m.Mailer.Mail(
194+
r.Context(),
180195
user.GetEmail(),
181196
withDefault(m.Config.Mailer.Subjects.Confirmation, "Confirm Your Email"),
182197
m.Config.Mailer.Templates.Confirmation,
@@ -197,6 +212,7 @@ func (m *TemplateMailer) ReauthenticateMail(r *http.Request, user *models.User,
197212
}
198213

199214
return m.Mailer.Mail(
215+
r.Context(),
200216
user.GetEmail(),
201217
withDefault(m.Config.Mailer.Subjects.Reauthentication, "Confirm reauthentication"),
202218
m.Config.Mailer.Templates.Reauthentication,
@@ -237,7 +253,10 @@ func (m *TemplateMailer) EmailChangeMail(r *http.Request, user *models.User, otp
237253
})
238254
}
239255

240-
errors := make(chan error)
256+
ctx, cancel := context.WithCancel(r.Context())
257+
defer cancel()
258+
259+
errors := make(chan error, len(emails))
241260
for _, email := range emails {
242261
path, err := getPath(
243262
m.Config.Mailer.URLPaths.EmailChange,
@@ -263,6 +282,7 @@ func (m *TemplateMailer) EmailChangeMail(r *http.Request, user *models.User, otp
263282
"RedirectTo": referrerURL,
264283
}
265284
errors <- m.Mailer.Mail(
285+
ctx,
266286
address,
267287
withDefault(m.Config.Mailer.Subjects.EmailChange, "Confirm Email Change"),
268288
template,
@@ -280,7 +300,6 @@ func (m *TemplateMailer) EmailChangeMail(r *http.Request, user *models.User, otp
280300
return e
281301
}
282302
}
283-
284303
return nil
285304
}
286305

@@ -305,6 +324,7 @@ func (m *TemplateMailer) RecoveryMail(r *http.Request, user *models.User, otp, r
305324
}
306325

307326
return m.Mailer.Mail(
327+
r.Context(),
308328
user.GetEmail(),
309329
withDefault(m.Config.Mailer.Subjects.Recovery, "Reset Your Password"),
310330
m.Config.Mailer.Templates.Recovery,
@@ -337,6 +357,7 @@ func (m *TemplateMailer) MagicLinkMail(r *http.Request, user *models.User, otp,
337357
}
338358

339359
return m.Mailer.Mail(
360+
r.Context(),
340361
user.GetEmail(),
341362
withDefault(m.Config.Mailer.Subjects.MagicLink, "Your Magic Link"),
342363
m.Config.Mailer.Templates.MagicLink,
@@ -347,19 +368,6 @@ func (m *TemplateMailer) MagicLinkMail(r *http.Request, user *models.User, otp,
347368
)
348369
}
349370

350-
// Send can be used to send one-off emails to users
351-
func (m TemplateMailer) Send(user *models.User, subject, body string, data map[string]interface{}) error {
352-
return m.Mailer.Mail(
353-
user.GetEmail(),
354-
subject,
355-
"",
356-
body,
357-
data,
358-
m.Headers("other"),
359-
"other",
360-
)
361-
}
362-
363371
// GetEmailActionLink returns a magiclink, recovery or invite link based on the actionType passed.
364372
func (m TemplateMailer) GetEmailActionLink(user *models.User, actionType, referrerURL string, externalURL *url.URL) (string, error) {
365373
var err error

0 commit comments

Comments
 (0)