From defd140221581940d7a93278dbba2b03402f5dd6 Mon Sep 17 00:00:00 2001 From: Joel Lee Date: Thu, 4 Apr 2024 22:51:54 +0900 Subject: [PATCH] feat: add send email Hook (#1512) ## What kind of change does this PR introduce? The send email hook serves as a substitute for the default email client (e.g. GoMail) across all endpoints. Supercedes #1496 as it was simpler to visualize the PR when starting from scratch --- internal/api/hooks.go | 23 ++++++++- internal/api/mail.go | 90 ++++++++++++++++++++++------------ internal/conf/configuration.go | 1 + internal/hooks/auth_hooks.go | 11 +++++ internal/mailer/mailer.go | 10 ++++ 5 files changed, 103 insertions(+), 32 deletions(-) diff --git a/internal/api/hooks.go b/internal/api/hooks.go index 94be41ea1..83b6761c9 100644 --- a/internal/api/hooks.go +++ b/internal/api/hooks.go @@ -183,11 +183,30 @@ func (a *API) invokeHTTPHook(ctx context.Context, r *http.Request, input, output var err error if response, err = a.runHTTPHook(ctx, r, a.config.Hook.SendSMS, input, output); err != nil { - return internalServerError("Error invoking custom SMS provider hook.").WithInternalError(err) + return internalServerError("Error invoking Send SMS hook.").WithInternalError(err) } if err := json.Unmarshal(response, hookOutput); err != nil { - return internalServerError("Error unmarshaling custom SMS provider hook output.").WithInternalError(err) + return internalServerError("Error unmarshaling Send SMS output.").WithInternalError(err) + } + case *hooks.SendEmailInput: + hookOutput, ok := output.(*hooks.SendEmailOutput) + if !ok { + panic("output should be *hooks.SendEmailOutput") + } + + var response []byte + var err error + + if response, err = a.runHTTPHook(ctx, r, a.config.Hook.SendEmail, input, output); err != nil { + return internalServerError("Error invoking Send Email hook.").WithInternalError(err) + } + if err != nil { + return err + } + + if err := json.Unmarshal(response, hookOutput); err != nil { + return internalServerError("Error unmarshaling Send Email hook output.").WithInternalError(err) } default: diff --git a/internal/api/mail.go b/internal/api/mail.go index ce150882a..c92d1150c 100644 --- a/internal/api/mail.go +++ b/internal/api/mail.go @@ -1,12 +1,12 @@ package api import ( + "github.com/supabase/auth/internal/hooks" + mail "github.com/supabase/auth/internal/mailer" "net/http" "strings" "time" - mail "github.com/supabase/auth/internal/mailer" - "github.com/badoux/checkmail" "github.com/fatih/structs" "github.com/pkg/errors" @@ -262,13 +262,10 @@ func (a *API) adminGenerateLink(w http.ResponseWriter, r *http.Request) error { } func (a *API) sendConfirmation(r *http.Request, tx *storage.Connection, u *models.User, flowType models.FlowType) error { - ctx := r.Context() - mailer := a.Mailer() config := a.config - otpLength := config.Mailer.OtpLength maxFrequency := config.SMTP.MaxFrequency - referrerURL := utilities.GetReferrer(r, config) - externalURL := getExternalHost(ctx) + otpLength := config.Mailer.OtpLength + var err error if err := validateSentWithinFrequencyLimit(u.ConfirmationSentAt, maxFrequency); err != nil { return err @@ -282,7 +279,8 @@ func (a *API) sendConfirmation(r *http.Request, tx *storage.Connection, u *model token := crypto.GenerateTokenHash(u.GetEmail(), otp) u.ConfirmationToken = addFlowPrefixToToken(token, flowType) now := time.Now() - if err := mailer.ConfirmationMail(r, u, otp, referrerURL, externalURL); err != nil { + err = a.sendEmail(r, u, mail.SignupVerification, otp, "", u.ConfirmationToken) + if err != nil { u.ConfirmationToken = oldToken return errors.Wrap(err, "Error sending confirmation email") } @@ -296,12 +294,8 @@ func (a *API) sendConfirmation(r *http.Request, tx *storage.Connection, u *model } func (a *API) sendInvite(r *http.Request, tx *storage.Connection, u *models.User) error { - ctx := r.Context() - mailer := a.Mailer() config := a.config otpLength := config.Mailer.OtpLength - referrerURL := utilities.GetReferrer(r, config) - externalURL := getExternalHost(ctx) var err error oldToken := u.ConfirmationToken otp, err := crypto.GenerateOtp(otpLength) @@ -311,7 +305,8 @@ func (a *API) sendInvite(r *http.Request, tx *storage.Connection, u *models.User } u.ConfirmationToken = crypto.GenerateTokenHash(u.GetEmail(), otp) now := time.Now() - if err := mailer.InviteMail(r, u, otp, referrerURL, externalURL); err != nil { + err = a.sendEmail(r, u, mail.InviteVerification, otp, "", u.ConfirmationToken) + if err != nil { u.ConfirmationToken = oldToken return errors.Wrap(err, "Error sending invite email") } @@ -326,13 +321,9 @@ func (a *API) sendInvite(r *http.Request, tx *storage.Connection, u *models.User } func (a *API) sendPasswordRecovery(r *http.Request, tx *storage.Connection, u *models.User, flowType models.FlowType) error { - ctx := r.Context() config := a.config maxFrequency := config.SMTP.MaxFrequency otpLength := config.Mailer.OtpLength - referrerURL := utilities.GetReferrer(r, config) - externalURL := getExternalHost(ctx) - mailer := a.Mailer() var err error if err := validateSentWithinFrequencyLimit(u.RecoverySentAt, maxFrequency); err != nil { return err @@ -347,7 +338,8 @@ func (a *API) sendPasswordRecovery(r *http.Request, tx *storage.Connection, u *m token := crypto.GenerateTokenHash(u.GetEmail(), otp) u.RecoveryToken = addFlowPrefixToToken(token, flowType) now := time.Now() - if err := mailer.RecoveryMail(r, u, otp, referrerURL, externalURL); err != nil { + err = a.sendEmail(r, u, mail.RecoveryVerification, otp, "", u.RecoveryToken) + if err != nil { u.RecoveryToken = oldToken return errors.Wrap(err, "Error sending recovery email") } @@ -364,7 +356,6 @@ func (a *API) sendReauthenticationOtp(r *http.Request, tx *storage.Connection, u config := a.config maxFrequency := config.SMTP.MaxFrequency otpLength := config.Mailer.OtpLength - mailer := a.Mailer() var err error if err := validateSentWithinFrequencyLimit(u.ReauthenticationSentAt, maxFrequency); err != nil { @@ -379,7 +370,8 @@ func (a *API) sendReauthenticationOtp(r *http.Request, tx *storage.Connection, u } u.ReauthenticationToken = crypto.GenerateTokenHash(u.GetEmail(), otp) now := time.Now() - if err := mailer.ReauthenticateMail(r, u, otp); err != nil { + err = a.sendEmail(r, u, mail.ReauthenticationVerification, otp, "", u.ReauthenticationToken) + if err != nil { u.ReauthenticationToken = oldToken return errors.Wrap(err, "Error sending reauthentication email") } @@ -393,13 +385,9 @@ func (a *API) sendReauthenticationOtp(r *http.Request, tx *storage.Connection, u } func (a *API) sendMagicLink(r *http.Request, tx *storage.Connection, u *models.User, flowType models.FlowType) error { - ctx := r.Context() - mailer := a.Mailer() config := a.config otpLength := config.Mailer.OtpLength maxFrequency := config.SMTP.MaxFrequency - referrerURL := utilities.GetReferrer(r, config) - externalURL := getExternalHost(ctx) var err error // since Magic Link is just a recovery with a different template and behaviour // around new users we will reuse the recovery db timer to prevent potential abuse @@ -417,7 +405,8 @@ func (a *API) sendMagicLink(r *http.Request, tx *storage.Connection, u *models.U u.RecoveryToken = addFlowPrefixToToken(token, flowType) now := time.Now() - if err := mailer.MagicLinkMail(r, u, otp, referrerURL, externalURL); err != nil { + err = a.sendEmail(r, u, mail.MagicLinkVerification, otp, "", u.RecoveryToken) + if err != nil { u.RecoveryToken = oldToken return errors.Wrap(err, "Error sending magic link email") } @@ -432,16 +421,12 @@ func (a *API) sendMagicLink(r *http.Request, tx *storage.Connection, u *models.U // sendEmailChange sends out an email change token to the new email. func (a *API) sendEmailChange(r *http.Request, tx *storage.Connection, u *models.User, email string, flowType models.FlowType) error { - ctx := r.Context() config := a.config otpLength := config.Mailer.OtpLength var err error - mailer := a.Mailer() if err := validateSentWithinFrequencyLimit(u.EmailChangeSentAt, config.SMTP.MaxFrequency); err != nil { return err } - referrerURL := utilities.GetReferrer(r, config) - externalURL := getExternalHost(ctx) otpNew, err := crypto.GenerateOtp(otpLength) if err != nil { @@ -465,7 +450,8 @@ func (a *API) sendEmailChange(r *http.Request, tx *storage.Connection, u *models u.EmailChangeConfirmStatus = zeroConfirmation now := time.Now() - if err := mailer.EmailChangeMail(r, u, otpNew, otpCurrent, referrerURL, externalURL); err != nil { + err = a.sendEmail(r, u, mail.EmailChangeVerification, otpCurrent, otpNew, u.EmailChangeTokenNew) + if err != nil { return err } @@ -502,3 +488,47 @@ func validateSentWithinFrequencyLimit(sentAt *time.Time, frequency time.Duration } return nil } + +func (a *API) sendEmail(r *http.Request, u *models.User, emailActionType, otp, otpNew, tokenHashWithPrefix string) error { + mailer := a.Mailer() + ctx := r.Context() + config := a.config + referrerURL := utilities.GetReferrer(r, config) + externalURL := getExternalHost(ctx) + if config.Hook.SendEmail.Enabled { + emailData := mail.EmailData{ + Token: otp, + EmailActionType: emailActionType, + RedirectTo: referrerURL, + SiteURL: externalURL.String(), + TokenHash: tokenHashWithPrefix, + } + if emailActionType == mail.EmailChangeVerification && config.Mailer.SecureEmailChangeEnabled && u.GetEmail() != "" { + emailData.TokenNew = otpNew + emailData.TokenHashNew = u.EmailChangeTokenCurrent + } + input := hooks.SendEmailInput{ + User: u, + EmailData: emailData, + } + output := hooks.SendEmailOutput{} + return a.invokeHTTPHook(ctx, r, &input, &output) + } + + switch emailActionType { + case mail.SignupVerification: + return mailer.ConfirmationMail(r, u, otp, referrerURL, externalURL) + case mail.MagicLinkVerification: + return mailer.MagicLinkMail(r, u, otp, referrerURL, externalURL) + case mail.ReauthenticationVerification: + return mailer.ReauthenticateMail(r, u, otp) + case mail.RecoveryVerification: + return mailer.RecoveryMail(r, u, otp, referrerURL, externalURL) + case mail.InviteVerification: + return mailer.InviteMail(r, u, otp, referrerURL, externalURL) + case mail.EmailChangeVerification: + return mailer.EmailChangeMail(r, u, otpNew, otp, referrerURL, externalURL) + default: + return errors.New("invalid email action type") + } +} diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index 330c9882b..7e3866209 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -451,6 +451,7 @@ type HookConfiguration struct { MFAVerificationAttempt ExtensibilityPointConfiguration `json:"mfa_verification_attempt" split_words:"true"` PasswordVerificationAttempt ExtensibilityPointConfiguration `json:"password_verification_attempt" split_words:"true"` CustomAccessToken ExtensibilityPointConfiguration `json:"custom_access_token" split_words:"true"` + SendEmail ExtensibilityPointConfiguration `json:"send_email" split_words:"true"` SendSMS ExtensibilityPointConfiguration `json:"send_sms" split_words:"true"` } diff --git a/internal/hooks/auth_hooks.go b/internal/hooks/auth_hooks.go index 6801f0266..bdd43a3c1 100644 --- a/internal/hooks/auth_hooks.go +++ b/internal/hooks/auth_hooks.go @@ -3,6 +3,7 @@ package hooks import ( "github.com/gofrs/uuid" "github.com/golang-jwt/jwt" + "github.com/supabase/auth/internal/mailer" "github.com/supabase/auth/internal/models" ) @@ -150,6 +151,16 @@ type SendSMSOutput struct { HookError AuthHookError `json:"error,omitempty"` } +type SendEmailInput struct { + User *models.User `json:"user"` + EmailData mailer.EmailData `json:"email_data"` +} + +type SendEmailOutput struct { + Success bool `json:"success"` + HookError AuthHookError `json:"error,omitempty"` +} + func (mf *MFAVerificationAttemptOutput) IsError() bool { return mf.HookError.Message != "" } diff --git a/internal/mailer/mailer.go b/internal/mailer/mailer.go index 34460c744..8768dbe20 100644 --- a/internal/mailer/mailer.go +++ b/internal/mailer/mailer.go @@ -32,6 +32,16 @@ type EmailParams struct { RedirectTo string } +type EmailData struct { + Token string `json:"token"` + TokenHash string `json:"token_hash"` + RedirectTo string `json:"redirect_to"` + EmailActionType string `json:"email_action_type"` + SiteURL string `json:"site_url"` + TokenNew string `json:"token_new"` + TokenHashNew string `json:"token_hash_new"` +} + // NewMailer returns a new gotrue mailer func NewMailer(globalConfig *conf.GlobalConfiguration) Mailer { mail := gomail.NewMessage()