Skip to content

Commit 047f851

Browse files
authored
feat: email address changed notification (#2181)
This PR adds support for sending the user an email notification when their email has been changed. 3 new environment variables are introduced: - `GOTRUE_MAILER_SUBJECTS_EMAIL_CHANGED_NOTIFICATION`: Email subject to use for password changed notification. - `GOTRUE_MAILER_TEMPLATES_EMAIL_CHANGED_NOTIFICATION`: The URL to specify a custom template. - `GOTRUE_MAILER_NOTIFICATIONS_EMAIL_CHANGED_ENABLED`: whether the notification is enabled or not. The feature is disabled by default. To enable it, the `GOTRUE_MAILER_NOTIFICATIONS_EMAIL_CHANGED_ENABLED` environment variable must be set to `true`. The default email will look as follows: <img width="956" height="461" alt="Screenshot 2025-09-26 at 15 28 18" src="https://github.com/user-attachments/assets/950812f5-5bfe-41ef-9368-9e479ab04f94" />
1 parent 01ebce1 commit 047f851

File tree

12 files changed

+307
-32
lines changed

12 files changed

+307
-32
lines changed

README.md

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -588,6 +588,10 @@ Email subject to use for email change confirmation. Defaults to `Confirm Email C
588588

589589
Email subject to use for password changed notification. Defaults to `Your password has been changed`.
590590

591+
`MAILER_SUBJECTS_EMAIL_CHANGED_NOTIFICATION` - `string`
592+
593+
Email subject to use for email changed notification. Defaults to `Your email address has been changed`.
594+
591595
`MAILER_TEMPLATES_INVITE` - `string`
592596

593597
URL path to an email template to use when inviting a user. (e.g. `https://www.example.com/path-to-email-template.html`)
@@ -667,7 +671,7 @@ Default Content (if template is unavailable):
667671
`MAILER_TEMPLATES_PASSWORD_CHANGED_NOTIFICATION` - `string`
668672

669673
URL path to an email template to use when notifying a user that their password has been changed. (e.g. `https://www.example.com/path-to-email-template.html`)
670-
`SiteURL` and `Email` variables are available.
674+
`Email` variables are available.
671675

672676
Default Content (if template is unavailable):
673677

@@ -679,12 +683,34 @@ Default Content (if template is unavailable):
679683
just been changed. If you did not make this change, please contact support
680684
immediately.
681685
</p>
686+
<p>If you did not make this change, please contact support.</p>
682687
```
683688

684-
`MAILER_NOTIFICATION_CONFIGURATIONS_PASSWORD_CHANGED_NOTIFICATION_ENABLED` - `bool`
689+
`GOTRUE_MAILER_NOTIFICATIONS_PASSWORD_CHANGED_ENABLED` - `bool`
685690

686691
Whether to send a notification email when a user's password is changed. Defaults to `false`.
687692

693+
`MAILER_TEMPLATES_EMAIL_CHANGED_NOTIFICATION` - `string`
694+
695+
URL path to an email template to use when notifying a user that their email has been changed. (e.g. `https://www.example.com/path-to-email-template.html`)
696+
`Email` and `OldEmail` variables are available.
697+
698+
Default Content (if template is unavailable):
699+
700+
```html
701+
<h2>Your email address has been changed</h2>
702+
703+
<p>
704+
The email address for your account has been changed from {{ .OldEmail }} to {{
705+
.Email }}.
706+
</p>
707+
<p>If you did not make this change, please contact support.</p>
708+
```
709+
710+
`GOTRUE_MAILER_NOTIFICATIONS_EMAIL_CHANGED_ENABLED` - `bool`
711+
712+
Whether to send a notification email when a user's email is changed. Defaults to `false`.
713+
688714
### Phone Auth
689715

690716
`SMS_AUTOCONFIRM` - `bool`

example.env

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ GOTRUE_MAILER_SUBJECTS_MAGIC_LINK="Your Magic Link"
3636
GOTRUE_MAILER_SUBJECTS_EMAIL_CHANGE="Confirm Email Change"
3737
GOTRUE_MAILER_SUBJECTS_INVITE="You have been invited"
3838
GOTRUE_MAILER_SUBJECTS_PASSWORD_CHANGED_NOTIFICATION="Your password has been changed"
39+
GOTRUE_MAILER_SUBJECTS_EMAIL_CHANGED_NOTIFICATION="Your email address has been changed"
3940
GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED="true"
4041

4142
# Custom mailer template config
@@ -45,9 +46,11 @@ GOTRUE_MAILER_TEMPLATES_RECOVERY=""
4546
GOTRUE_MAILER_TEMPLATES_MAGIC_LINK=""
4647
GOTRUE_MAILER_TEMPLATES_EMAIL_CHANGE=""
4748
GOTRUE_MAILER_TEMPLATES_PASSWORD_CHANGED_NOTIFICATION=""
49+
GOTRUE_MAILER_TEMPLATES_EMAIL_CHANGED_NOTIFICATION=""
4850

4951
# Account changes notifications configuration
5052
GOTRUE_MAILER_NOTIFICATIONS_PASSWORD_CHANGED_ENABLED="false"
53+
GOTRUE_MAILER_NOTIFICATIONS_EMAIL_CHANGED_ENABLED="false"
5154

5255
# Signup config
5356
GOTRUE_DISABLE_SIGNUP="false"

internal/api/mail.go

Lines changed: 78 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,11 @@ func (a *API) sendConfirmation(r *http.Request, tx *storage.Connection, u *model
328328
token := crypto.GenerateTokenHash(u.GetEmail(), otp)
329329
u.ConfirmationToken = addFlowPrefixToToken(token, flowType)
330330
now := time.Now()
331-
if err = a.sendEmail(r, tx, u, mail.SignupVerification, otp, "", u.ConfirmationToken); err != nil {
331+
if err = a.sendEmail(r, tx, u, sendEmailParams{
332+
emailActionType: mail.SignupVerification,
333+
otp: otp,
334+
tokenHashWithPrefix: u.ConfirmationToken,
335+
}); err != nil {
332336
u.ConfirmationToken = oldToken
333337
if errors.Is(err, EmailRateLimitExceeded) {
334338
return apierrors.NewTooManyRequestsError(apierrors.ErrorCodeOverEmailSendRateLimit, EmailRateLimitExceeded.Error())
@@ -358,7 +362,12 @@ func (a *API) sendInvite(r *http.Request, tx *storage.Connection, u *models.User
358362

359363
u.ConfirmationToken = crypto.GenerateTokenHash(u.GetEmail(), otp)
360364
now := time.Now()
361-
if err = a.sendEmail(r, tx, u, mail.InviteVerification, otp, "", u.ConfirmationToken); err != nil {
365+
err = a.sendEmail(r, tx, u, sendEmailParams{
366+
emailActionType: mail.InviteVerification,
367+
otp: otp,
368+
tokenHashWithPrefix: u.ConfirmationToken,
369+
})
370+
if err != nil {
362371
u.ConfirmationToken = oldToken
363372
if errors.Is(err, EmailRateLimitExceeded) {
364373
return apierrors.NewTooManyRequestsError(apierrors.ErrorCodeOverEmailSendRateLimit, EmailRateLimitExceeded.Error())
@@ -396,7 +405,12 @@ func (a *API) sendPasswordRecovery(r *http.Request, tx *storage.Connection, u *m
396405
token := crypto.GenerateTokenHash(u.GetEmail(), otp)
397406
u.RecoveryToken = addFlowPrefixToToken(token, flowType)
398407
now := time.Now()
399-
if err := a.sendEmail(r, tx, u, mail.RecoveryVerification, otp, "", u.RecoveryToken); err != nil {
408+
err := a.sendEmail(r, tx, u, sendEmailParams{
409+
emailActionType: mail.RecoveryVerification,
410+
otp: otp,
411+
tokenHashWithPrefix: u.RecoveryToken,
412+
})
413+
if err != nil {
400414
u.RecoveryToken = oldToken
401415
if errors.Is(err, EmailRateLimitExceeded) {
402416
return apierrors.NewTooManyRequestsError(apierrors.ErrorCodeOverEmailSendRateLimit, EmailRateLimitExceeded.Error())
@@ -433,7 +447,12 @@ func (a *API) sendReauthenticationOtp(r *http.Request, tx *storage.Connection, u
433447
u.ReauthenticationToken = crypto.GenerateTokenHash(u.GetEmail(), otp)
434448
now := time.Now()
435449

436-
if err := a.sendEmail(r, tx, u, mail.ReauthenticationVerification, otp, "", u.ReauthenticationToken); err != nil {
450+
err := a.sendEmail(r, tx, u, sendEmailParams{
451+
emailActionType: mail.ReauthenticationVerification,
452+
otp: otp,
453+
tokenHashWithPrefix: u.ReauthenticationToken,
454+
})
455+
if err != nil {
437456
u.ReauthenticationToken = oldToken
438457
if errors.Is(err, EmailRateLimitExceeded) {
439458
return apierrors.NewTooManyRequestsError(apierrors.ErrorCodeOverEmailSendRateLimit, EmailRateLimitExceeded.Error())
@@ -472,7 +491,11 @@ func (a *API) sendMagicLink(r *http.Request, tx *storage.Connection, u *models.U
472491
u.RecoveryToken = addFlowPrefixToToken(token, flowType)
473492

474493
now := time.Now()
475-
if err = a.sendEmail(r, tx, u, mail.MagicLinkVerification, otp, "", u.RecoveryToken); err != nil {
494+
if err = a.sendEmail(r, tx, u, sendEmailParams{
495+
emailActionType: mail.MagicLinkVerification,
496+
otp: otp,
497+
tokenHashWithPrefix: u.RecoveryToken,
498+
}); err != nil {
476499
u.RecoveryToken = oldToken
477500
if errors.Is(err, EmailRateLimitExceeded) {
478501
return apierrors.NewTooManyRequestsError(apierrors.ErrorCodeOverEmailSendRateLimit, EmailRateLimitExceeded.Error())
@@ -519,7 +542,13 @@ func (a *API) sendEmailChange(r *http.Request, tx *storage.Connection, u *models
519542
u.EmailChangeConfirmStatus = zeroConfirmation
520543
now := time.Now()
521544

522-
if err := a.sendEmail(r, tx, u, mail.EmailChangeVerification, otpCurrent, otpNew, u.EmailChangeTokenNew); err != nil {
545+
err := a.sendEmail(r, tx, u, sendEmailParams{
546+
emailActionType: mail.EmailChangeVerification,
547+
otp: otpCurrent,
548+
otpNew: otpNew,
549+
tokenHashWithPrefix: u.EmailChangeTokenNew,
550+
})
551+
if err != nil {
523552
if errors.Is(err, EmailRateLimitExceeded) {
524553
return apierrors.NewTooManyRequestsError(apierrors.ErrorCodeOverEmailSendRateLimit, EmailRateLimitExceeded.Error())
525554
} else if herr, ok := err.(*HTTPError); ok {
@@ -556,7 +585,10 @@ func (a *API) sendEmailChange(r *http.Request, tx *storage.Connection, u *models
556585
}
557586

558587
func (a *API) sendPasswordChangedNotification(r *http.Request, tx *storage.Connection, u *models.User) error {
559-
if err := a.sendEmail(r, tx, u, mail.PasswordChangedNotification, "", "", ""); err != nil {
588+
err := a.sendEmail(r, tx, u, sendEmailParams{
589+
emailActionType: mail.PasswordChangedNotification,
590+
})
591+
if err != nil {
560592
if errors.Is(err, EmailRateLimitExceeded) {
561593
return apierrors.NewTooManyRequestsError(apierrors.ErrorCodeOverEmailSendRateLimit, EmailRateLimitExceeded.Error())
562594
} else if herr, ok := err.(*HTTPError); ok {
@@ -568,6 +600,23 @@ func (a *API) sendPasswordChangedNotification(r *http.Request, tx *storage.Conne
568600
return nil
569601
}
570602

603+
func (a *API) sendEmailChangedNotification(r *http.Request, tx *storage.Connection, u *models.User, oldEmail string) error {
604+
err := a.sendEmail(r, tx, u, sendEmailParams{
605+
emailActionType: mail.EmailChangedNotification,
606+
oldEmail: oldEmail,
607+
})
608+
if err != nil {
609+
if errors.Is(err, EmailRateLimitExceeded) {
610+
return apierrors.NewTooManyRequestsError(apierrors.ErrorCodeOverEmailSendRateLimit, EmailRateLimitExceeded.Error())
611+
} else if herr, ok := err.(*HTTPError); ok {
612+
return herr
613+
}
614+
return apierrors.NewInternalServerError("Error sending email changed notification email").WithInternalError(err)
615+
}
616+
617+
return nil
618+
}
619+
571620
func (a *API) validateEmail(email string) (string, error) {
572621
if email == "" {
573622
return "", apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "An email address is required")
@@ -608,13 +657,22 @@ func (a *API) checkEmailAddressAuthorization(email string) bool {
608657
return true
609658
}
610659

611-
func (a *API) sendEmail(r *http.Request, tx *storage.Connection, u *models.User, emailActionType, otp, otpNew, tokenHashWithPrefix string) error {
660+
type sendEmailParams struct {
661+
emailActionType string
662+
otp string
663+
otpNew string
664+
tokenHashWithPrefix string
665+
oldEmail string
666+
}
667+
668+
func (a *API) sendEmail(r *http.Request, tx *storage.Connection, u *models.User, params sendEmailParams) error {
612669
ctx := r.Context()
613670
config := a.config
614671
referrerURL := utilities.GetReferrer(r, config)
615672
externalURL := getExternalHost(ctx)
673+
otp := params.otp
616674

617-
if emailActionType != mail.EmailChangeVerification {
675+
if params.emailActionType != mail.EmailChangeVerification {
618676
if u.GetEmail() != "" && !a.checkEmailAddressAuthorization(u.GetEmail()) {
619677
return apierrors.NewBadRequestError(apierrors.ErrorCodeEmailAddressNotAuthorized, "Email address %q cannot be used as it is not authorized", u.GetEmail())
620678
}
@@ -659,7 +717,7 @@ func (a *API) sendEmail(r *http.Request, tx *storage.Connection, u *models.User,
659717

660718
if config.Hook.SendEmail.Enabled {
661719
// When secure email change is disabled, we place the token for the new email on emailData.Token
662-
if emailActionType == mail.EmailChangeVerification && !config.Mailer.SecureEmailChangeEnabled && u.GetEmail() != "" {
720+
if params.emailActionType == mail.EmailChangeVerification && !config.Mailer.SecureEmailChangeEnabled && u.GetEmail() != "" {
663721

664722
// BUG(cstockton): This introduced a bug which mismatched the token
665723
// and hash fields, such that:
@@ -676,26 +734,26 @@ func (a *API) sendEmail(r *http.Request, tx *storage.Connection, u *models.User,
676734
// Token Always contains the Token for user.email_new
677735
// TokenHash Always contains the Hash for user.email_new
678736
//
679-
otp = otpNew
737+
otp = params.otpNew
680738
}
681739

682740
emailData := mail.EmailData{
683741
Token: otp,
684-
EmailActionType: emailActionType,
742+
EmailActionType: params.emailActionType,
685743
RedirectTo: referrerURL,
686744
SiteURL: externalURL.String(),
687-
TokenHash: tokenHashWithPrefix,
745+
TokenHash: params.tokenHashWithPrefix,
688746
}
689-
if emailActionType == mail.EmailChangeVerification {
747+
if params.emailActionType == mail.EmailChangeVerification {
690748
if config.Mailer.SecureEmailChangeEnabled && u.GetEmail() != "" {
691-
emailData.TokenNew = otpNew
749+
emailData.TokenNew = params.otpNew
692750
emailData.TokenHashNew = u.EmailChangeTokenCurrent
693751
} else if emailData.Token == "" && u.EmailChange != "" {
694752

695753
// BUG(cstockton): This matches the current behavior but is not
696754
// intuitive and should be changed in a future release. See the
697755
// comment above for more details.
698-
emailData.Token = otpNew
756+
emailData.Token = params.otpNew
699757
}
700758
}
701759
input := v0hooks.SendEmailInput{
@@ -708,7 +766,7 @@ func (a *API) sendEmail(r *http.Request, tx *storage.Connection, u *models.User,
708766

709767
mr := a.Mailer()
710768
var err error
711-
switch emailActionType {
769+
switch params.emailActionType {
712770
case mail.SignupVerification:
713771
err = mr.ConfirmationMail(r, u, otp, referrerURL, externalURL)
714772
case mail.MagicLinkVerification:
@@ -720,9 +778,11 @@ func (a *API) sendEmail(r *http.Request, tx *storage.Connection, u *models.User,
720778
case mail.InviteVerification:
721779
err = mr.InviteMail(r, u, otp, referrerURL, externalURL)
722780
case mail.EmailChangeVerification:
723-
err = mr.EmailChangeMail(r, u, otpNew, otp, referrerURL, externalURL)
781+
err = mr.EmailChangeMail(r, u, params.otpNew, otp, referrerURL, externalURL)
724782
case mail.PasswordChangedNotification:
725783
err = mr.PasswordChangedNotificationMail(r, u)
784+
case mail.EmailChangedNotification:
785+
err = mr.EmailChangedNotificationMail(r, u, params.oldEmail)
726786
default:
727787
err = errors.New("invalid email action type")
728788
}

internal/api/verify.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111

1212
"github.com/fatih/structs"
1313
"github.com/sethvargo/go-password/password"
14+
"github.com/sirupsen/logrus"
1415
"github.com/supabase/auth/internal/api/apierrors"
1516
"github.com/supabase/auth/internal/api/provider"
1617
"github.com/supabase/auth/internal/api/sms_provider"
@@ -559,6 +560,7 @@ func (a *API) emailChangeVerify(r *http.Request, conn *storage.Connection, param
559560
}
560561

561562
// one email is confirmed at this point if GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED is enabled
563+
oldEmail := user.GetEmail()
562564
err := conn.Transaction(func(tx *storage.Connection) error {
563565
if terr := models.NewAuditLogEntry(config.AuditLog, r, tx, user, models.UserModifiedAction, "", nil); terr != nil {
564566
return terr
@@ -603,6 +605,14 @@ func (a *API) emailChangeVerify(r *http.Request, conn *storage.Connection, param
603605
return nil, err
604606
}
605607

608+
// send an Email Changed email notification to the user
609+
if config.Mailer.Notifications.EmailChangedEnabled && user.GetEmail() != "" {
610+
if err := a.sendEmailChangedNotification(r, conn, user, oldEmail); err != nil {
611+
// we don't want to fail the whole request if the email can't be sent
612+
logrus.WithError(err).Warn("Unable to send email changed notification")
613+
}
614+
}
615+
606616
return user, nil
607617
}
608618

0 commit comments

Comments
 (0)