Skip to content

Commit

Permalink
feat: add send email Hook (#1512)
Browse files Browse the repository at this point in the history
## 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
  • Loading branch information
J0 committed Apr 4, 2024
1 parent 8002ccb commit cf42e02
Show file tree
Hide file tree
Showing 5 changed files with 103 additions and 32 deletions.
23 changes: 21 additions & 2 deletions internal/api/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
90 changes: 60 additions & 30 deletions internal/api/mail.go
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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")
}
Expand All @@ -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)
Expand All @@ -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")
}
Expand All @@ -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
Expand All @@ -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")
}
Expand All @@ -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 {
Expand All @@ -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")
}
Expand All @@ -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
Expand All @@ -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")
}
Expand All @@ -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 {
Expand All @@ -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
}

Expand Down Expand Up @@ -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")
}
}
1 change: 1 addition & 0 deletions internal/conf/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}

Expand Down
11 changes: 11 additions & 0 deletions internal/hooks/auth_hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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 != ""
}
Expand Down
10 changes: 10 additions & 0 deletions internal/mailer/mailer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down

0 comments on commit cf42e02

Please sign in to comment.