From 12dd3238bdae5d52e8ee5b94e1a83fe07f03d64e Mon Sep 17 00:00:00 2001 From: issuedat <165281975+issuedat@users.noreply.github.com> Date: Fri, 26 Sep 2025 15:24:30 +0200 Subject: [PATCH] feat: email address changed notification --- README.md | 30 +++- example.env | 3 + internal/api/mail.go | 96 ++++++++++--- internal/api/verify.go | 10 ++ internal/api/verify_test.go | 132 +++++++++++++++++- internal/conf/configuration.go | 2 + internal/mailer/mailer.go | 2 + internal/mailer/mockclient/mockclient.go | 31 +++- internal/mailer/templatemailer/template.go | 2 + .../mailer/templatemailer/templatemailer.go | 25 +++- internal/reloader/testdata/50_example.env | 3 + .../reloader/testdata/60_example_newline.env | 3 + 12 files changed, 307 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 22f49d113..a060b23e0 100644 --- a/README.md +++ b/README.md @@ -588,6 +588,10 @@ Email subject to use for email change confirmation. Defaults to `Confirm Email C Email subject to use for password changed notification. Defaults to `Your password has been changed`. +`MAILER_SUBJECTS_EMAIL_CHANGED_NOTIFICATION` - `string` + +Email subject to use for email changed notification. Defaults to `Your email address has been changed`. + `MAILER_TEMPLATES_INVITE` - `string` 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): `MAILER_TEMPLATES_PASSWORD_CHANGED_NOTIFICATION` - `string` 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`) -`SiteURL` and `Email` variables are available. +`Email` variables are available. Default Content (if template is unavailable): @@ -679,12 +683,34 @@ Default Content (if template is unavailable): just been changed. If you did not make this change, please contact support immediately.

+

If you did not make this change, please contact support.

``` -`MAILER_NOTIFICATION_CONFIGURATIONS_PASSWORD_CHANGED_NOTIFICATION_ENABLED` - `bool` +`GOTRUE_MAILER_NOTIFICATIONS_PASSWORD_CHANGED_ENABLED` - `bool` Whether to send a notification email when a user's password is changed. Defaults to `false`. +`MAILER_TEMPLATES_EMAIL_CHANGED_NOTIFICATION` - `string` + +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`) +`Email` and `OldEmail` variables are available. + +Default Content (if template is unavailable): + +```html +

Your email address has been changed

+ +

+ The email address for your account has been changed from {{ .OldEmail }} to {{ + .Email }}. +

+

If you did not make this change, please contact support.

+``` + +`GOTRUE_MAILER_NOTIFICATIONS_EMAIL_CHANGED_ENABLED` - `bool` + +Whether to send a notification email when a user's email is changed. Defaults to `false`. + ### Phone Auth `SMS_AUTOCONFIRM` - `bool` diff --git a/example.env b/example.env index 096bac815..dbec15670 100644 --- a/example.env +++ b/example.env @@ -36,6 +36,7 @@ GOTRUE_MAILER_SUBJECTS_MAGIC_LINK="Your Magic Link" GOTRUE_MAILER_SUBJECTS_EMAIL_CHANGE="Confirm Email Change" GOTRUE_MAILER_SUBJECTS_INVITE="You have been invited" GOTRUE_MAILER_SUBJECTS_PASSWORD_CHANGED_NOTIFICATION="Your password has been changed" +GOTRUE_MAILER_SUBJECTS_EMAIL_CHANGED_NOTIFICATION="Your email address has been changed" GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED="true" # Custom mailer template config @@ -45,9 +46,11 @@ GOTRUE_MAILER_TEMPLATES_RECOVERY="" GOTRUE_MAILER_TEMPLATES_MAGIC_LINK="" GOTRUE_MAILER_TEMPLATES_EMAIL_CHANGE="" GOTRUE_MAILER_TEMPLATES_PASSWORD_CHANGED_NOTIFICATION="" +GOTRUE_MAILER_TEMPLATES_EMAIL_CHANGED_NOTIFICATION="" # Account changes notifications configuration GOTRUE_MAILER_NOTIFICATIONS_PASSWORD_CHANGED_ENABLED="false" +GOTRUE_MAILER_NOTIFICATIONS_EMAIL_CHANGED_ENABLED="false" # Signup config GOTRUE_DISABLE_SIGNUP="false" diff --git a/internal/api/mail.go b/internal/api/mail.go index 70856bce4..c6aff0c85 100644 --- a/internal/api/mail.go +++ b/internal/api/mail.go @@ -328,7 +328,11 @@ 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 = a.sendEmail(r, tx, u, mail.SignupVerification, otp, "", u.ConfirmationToken); err != nil { + if err = a.sendEmail(r, tx, u, sendEmailParams{ + emailActionType: mail.SignupVerification, + otp: otp, + tokenHashWithPrefix: u.ConfirmationToken, + }); err != nil { u.ConfirmationToken = oldToken if errors.Is(err, EmailRateLimitExceeded) { return apierrors.NewTooManyRequestsError(apierrors.ErrorCodeOverEmailSendRateLimit, EmailRateLimitExceeded.Error()) @@ -358,7 +362,12 @@ 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 = a.sendEmail(r, tx, u, mail.InviteVerification, otp, "", u.ConfirmationToken); err != nil { + err = a.sendEmail(r, tx, u, sendEmailParams{ + emailActionType: mail.InviteVerification, + otp: otp, + tokenHashWithPrefix: u.ConfirmationToken, + }) + if err != nil { u.ConfirmationToken = oldToken if errors.Is(err, EmailRateLimitExceeded) { return apierrors.NewTooManyRequestsError(apierrors.ErrorCodeOverEmailSendRateLimit, EmailRateLimitExceeded.Error()) @@ -396,7 +405,12 @@ 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 := a.sendEmail(r, tx, u, mail.RecoveryVerification, otp, "", u.RecoveryToken); err != nil { + err := a.sendEmail(r, tx, u, sendEmailParams{ + emailActionType: mail.RecoveryVerification, + otp: otp, + tokenHashWithPrefix: u.RecoveryToken, + }) + if err != nil { u.RecoveryToken = oldToken if errors.Is(err, EmailRateLimitExceeded) { return apierrors.NewTooManyRequestsError(apierrors.ErrorCodeOverEmailSendRateLimit, EmailRateLimitExceeded.Error()) @@ -433,7 +447,12 @@ func (a *API) sendReauthenticationOtp(r *http.Request, tx *storage.Connection, u u.ReauthenticationToken = crypto.GenerateTokenHash(u.GetEmail(), otp) now := time.Now() - if err := a.sendEmail(r, tx, u, mail.ReauthenticationVerification, otp, "", u.ReauthenticationToken); err != nil { + err := a.sendEmail(r, tx, u, sendEmailParams{ + emailActionType: mail.ReauthenticationVerification, + otp: otp, + tokenHashWithPrefix: u.ReauthenticationToken, + }) + if err != nil { u.ReauthenticationToken = oldToken if errors.Is(err, EmailRateLimitExceeded) { return apierrors.NewTooManyRequestsError(apierrors.ErrorCodeOverEmailSendRateLimit, EmailRateLimitExceeded.Error()) @@ -472,7 +491,11 @@ func (a *API) sendMagicLink(r *http.Request, tx *storage.Connection, u *models.U u.RecoveryToken = addFlowPrefixToToken(token, flowType) now := time.Now() - if err = a.sendEmail(r, tx, u, mail.MagicLinkVerification, otp, "", u.RecoveryToken); err != nil { + if err = a.sendEmail(r, tx, u, sendEmailParams{ + emailActionType: mail.MagicLinkVerification, + otp: otp, + tokenHashWithPrefix: u.RecoveryToken, + }); err != nil { u.RecoveryToken = oldToken if errors.Is(err, EmailRateLimitExceeded) { return apierrors.NewTooManyRequestsError(apierrors.ErrorCodeOverEmailSendRateLimit, EmailRateLimitExceeded.Error()) @@ -519,7 +542,13 @@ func (a *API) sendEmailChange(r *http.Request, tx *storage.Connection, u *models u.EmailChangeConfirmStatus = zeroConfirmation now := time.Now() - if err := a.sendEmail(r, tx, u, mail.EmailChangeVerification, otpCurrent, otpNew, u.EmailChangeTokenNew); err != nil { + err := a.sendEmail(r, tx, u, sendEmailParams{ + emailActionType: mail.EmailChangeVerification, + otp: otpCurrent, + otpNew: otpNew, + tokenHashWithPrefix: u.EmailChangeTokenNew, + }) + if err != nil { if errors.Is(err, EmailRateLimitExceeded) { return apierrors.NewTooManyRequestsError(apierrors.ErrorCodeOverEmailSendRateLimit, EmailRateLimitExceeded.Error()) } else if herr, ok := err.(*HTTPError); ok { @@ -556,7 +585,10 @@ func (a *API) sendEmailChange(r *http.Request, tx *storage.Connection, u *models } func (a *API) sendPasswordChangedNotification(r *http.Request, tx *storage.Connection, u *models.User) error { - if err := a.sendEmail(r, tx, u, mail.PasswordChangedNotification, "", "", ""); err != nil { + err := a.sendEmail(r, tx, u, sendEmailParams{ + emailActionType: mail.PasswordChangedNotification, + }) + if err != nil { if errors.Is(err, EmailRateLimitExceeded) { return apierrors.NewTooManyRequestsError(apierrors.ErrorCodeOverEmailSendRateLimit, EmailRateLimitExceeded.Error()) } else if herr, ok := err.(*HTTPError); ok { @@ -568,6 +600,23 @@ func (a *API) sendPasswordChangedNotification(r *http.Request, tx *storage.Conne return nil } +func (a *API) sendEmailChangedNotification(r *http.Request, tx *storage.Connection, u *models.User, oldEmail string) error { + err := a.sendEmail(r, tx, u, sendEmailParams{ + emailActionType: mail.EmailChangedNotification, + oldEmail: oldEmail, + }) + if err != nil { + if errors.Is(err, EmailRateLimitExceeded) { + return apierrors.NewTooManyRequestsError(apierrors.ErrorCodeOverEmailSendRateLimit, EmailRateLimitExceeded.Error()) + } else if herr, ok := err.(*HTTPError); ok { + return herr + } + return apierrors.NewInternalServerError("Error sending email changed notification email").WithInternalError(err) + } + + return nil +} + func (a *API) validateEmail(email string) (string, error) { if email == "" { return "", apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "An email address is required") @@ -608,13 +657,22 @@ func (a *API) checkEmailAddressAuthorization(email string) bool { return true } -func (a *API) sendEmail(r *http.Request, tx *storage.Connection, u *models.User, emailActionType, otp, otpNew, tokenHashWithPrefix string) error { +type sendEmailParams struct { + emailActionType string + otp string + otpNew string + tokenHashWithPrefix string + oldEmail string +} + +func (a *API) sendEmail(r *http.Request, tx *storage.Connection, u *models.User, params sendEmailParams) error { ctx := r.Context() config := a.config referrerURL := utilities.GetReferrer(r, config) externalURL := getExternalHost(ctx) + otp := params.otp - if emailActionType != mail.EmailChangeVerification { + if params.emailActionType != mail.EmailChangeVerification { if u.GetEmail() != "" && !a.checkEmailAddressAuthorization(u.GetEmail()) { return apierrors.NewBadRequestError(apierrors.ErrorCodeEmailAddressNotAuthorized, "Email address %q cannot be used as it is not authorized", u.GetEmail()) } @@ -659,7 +717,7 @@ func (a *API) sendEmail(r *http.Request, tx *storage.Connection, u *models.User, if config.Hook.SendEmail.Enabled { // When secure email change is disabled, we place the token for the new email on emailData.Token - if emailActionType == mail.EmailChangeVerification && !config.Mailer.SecureEmailChangeEnabled && u.GetEmail() != "" { + if params.emailActionType == mail.EmailChangeVerification && !config.Mailer.SecureEmailChangeEnabled && u.GetEmail() != "" { // BUG(cstockton): This introduced a bug which mismatched the token // and hash fields, such that: @@ -676,26 +734,26 @@ func (a *API) sendEmail(r *http.Request, tx *storage.Connection, u *models.User, // Token Always contains the Token for user.email_new // TokenHash Always contains the Hash for user.email_new // - otp = otpNew + otp = params.otpNew } emailData := mail.EmailData{ Token: otp, - EmailActionType: emailActionType, + EmailActionType: params.emailActionType, RedirectTo: referrerURL, SiteURL: externalURL.String(), - TokenHash: tokenHashWithPrefix, + TokenHash: params.tokenHashWithPrefix, } - if emailActionType == mail.EmailChangeVerification { + if params.emailActionType == mail.EmailChangeVerification { if config.Mailer.SecureEmailChangeEnabled && u.GetEmail() != "" { - emailData.TokenNew = otpNew + emailData.TokenNew = params.otpNew emailData.TokenHashNew = u.EmailChangeTokenCurrent } else if emailData.Token == "" && u.EmailChange != "" { // BUG(cstockton): This matches the current behavior but is not // intuitive and should be changed in a future release. See the // comment above for more details. - emailData.Token = otpNew + emailData.Token = params.otpNew } } input := v0hooks.SendEmailInput{ @@ -708,7 +766,7 @@ func (a *API) sendEmail(r *http.Request, tx *storage.Connection, u *models.User, mr := a.Mailer() var err error - switch emailActionType { + switch params.emailActionType { case mail.SignupVerification: err = mr.ConfirmationMail(r, u, otp, referrerURL, externalURL) case mail.MagicLinkVerification: @@ -720,9 +778,11 @@ func (a *API) sendEmail(r *http.Request, tx *storage.Connection, u *models.User, case mail.InviteVerification: err = mr.InviteMail(r, u, otp, referrerURL, externalURL) case mail.EmailChangeVerification: - err = mr.EmailChangeMail(r, u, otpNew, otp, referrerURL, externalURL) + err = mr.EmailChangeMail(r, u, params.otpNew, otp, referrerURL, externalURL) case mail.PasswordChangedNotification: err = mr.PasswordChangedNotificationMail(r, u) + case mail.EmailChangedNotification: + err = mr.EmailChangedNotificationMail(r, u, params.oldEmail) default: err = errors.New("invalid email action type") } diff --git a/internal/api/verify.go b/internal/api/verify.go index 20a23b0ce..532ce4e5e 100644 --- a/internal/api/verify.go +++ b/internal/api/verify.go @@ -11,6 +11,7 @@ import ( "github.com/fatih/structs" "github.com/sethvargo/go-password/password" + "github.com/sirupsen/logrus" "github.com/supabase/auth/internal/api/apierrors" "github.com/supabase/auth/internal/api/provider" "github.com/supabase/auth/internal/api/sms_provider" @@ -559,6 +560,7 @@ func (a *API) emailChangeVerify(r *http.Request, conn *storage.Connection, param } // one email is confirmed at this point if GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED is enabled + oldEmail := user.GetEmail() err := conn.Transaction(func(tx *storage.Connection) error { if terr := models.NewAuditLogEntry(config.AuditLog, r, tx, user, models.UserModifiedAction, "", nil); terr != nil { return terr @@ -603,6 +605,14 @@ func (a *API) emailChangeVerify(r *http.Request, conn *storage.Connection, param return nil, err } + // send an Email Changed email notification to the user + if config.Mailer.Notifications.EmailChangedEnabled && user.GetEmail() != "" { + if err := a.sendEmailChangedNotification(r, conn, user, oldEmail); err != nil { + // we don't want to fail the whole request if the email can't be sent + logrus.WithError(err).Warn("Unable to send email changed notification") + } + } + return user, nil } diff --git a/internal/api/verify_test.go b/internal/api/verify_test.go index e0dc5ed73..6da850e2b 100644 --- a/internal/api/verify_test.go +++ b/internal/api/verify_test.go @@ -14,6 +14,7 @@ import ( "github.com/supabase/auth/internal/api/apierrors" mail "github.com/supabase/auth/internal/mailer" + "github.com/supabase/auth/internal/mailer/mockclient" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -27,15 +28,18 @@ type VerifyTestSuite struct { suite.Suite API *API Config *conf.GlobalConfiguration + Mailer mail.Mailer } func TestVerify(t *testing.T) { - api, config, err := setupAPIForTest() + mockMailer := &mockclient.MockMailer{} + api, config, err := setupAPIForTest(WithMailer(mockMailer)) require.NoError(t, err) ts := &VerifyTestSuite{ API: api, Config: config, + Mailer: mockMailer, } defer api.db.Close() @@ -1279,3 +1283,129 @@ func (ts *VerifyTestSuite) TestVerifyValidateParams() { }) } } + +func (ts *VerifyTestSuite) TestVeryEmailChangeSendsNotificationEmail() { + currentEmail := "test@example.com" + newEmail := "new@example.com" + + // Change from new email to current email and back to new email + cases := []struct { + desc string + body map[string]interface{} + currentEmail string + newEmail string + notificationEnabled bool + expectedNotificationsCalled int + }{ + { + desc: "Email change notification enabled", + body: map[string]interface{}{ + "email": newEmail, + }, + currentEmail: currentEmail, + newEmail: newEmail, + notificationEnabled: true, + expectedNotificationsCalled: 1, + }, + { + desc: "Email change notification disabled", + body: map[string]interface{}{ + "email": currentEmail, + }, + currentEmail: newEmail, + newEmail: currentEmail, + notificationEnabled: false, + expectedNotificationsCalled: 0, + }, + } + + for _, c := range cases { + ts.Run(c.desc, func() { + ts.Config.Mailer.Notifications.EmailChangedEnabled = c.notificationEnabled + u, err := models.FindUserByEmailAndAudience(ts.API.db, c.currentEmail, ts.Config.JWT.Aud) + require.NoError(ts.T(), err) + + // Get the mock mailer and reset it + mockMailer, ok := ts.Mailer.(*mockclient.MockMailer) + require.True(ts.T(), ok, "Mailer is not of type *MockMailer") + mockMailer.Reset() + + // reset user + u.EmailChangeSentAt = nil + u.EmailChangeTokenCurrent = "" + u.EmailChangeTokenNew = "" + require.NoError(ts.T(), ts.API.db.Update(u)) + require.NoError(ts.T(), models.ClearAllOneTimeTokensForUser(ts.API.db, u.ID)) + + // Request body + var buffer bytes.Buffer + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(c.body)) + + // Setup request + req := httptest.NewRequest(http.MethodPut, "http://localhost/user", &buffer) + req.Header.Set("Content-Type", "application/json") + + // Generate access token for request and a mock session + var token string + session, err := models.NewSession(u.ID, nil) + require.NoError(ts.T(), err) + require.NoError(ts.T(), ts.API.db.Create(session)) + + token, _, err = ts.API.generateAccessToken(req, ts.API.db, u, &session.ID, models.MagicLink) + require.NoError(ts.T(), err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + + // Setup response recorder + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + assert.Equal(ts.T(), http.StatusOK, w.Code) + + u, err = models.FindUserByEmailAndAudience(ts.API.db, c.currentEmail, ts.Config.JWT.Aud) + require.NoError(ts.T(), err) + + currentTokenHash := u.EmailChangeTokenCurrent + newTokenHash := u.EmailChangeTokenNew + + u, err = models.FindUserByEmailAndAudience(ts.API.db, c.currentEmail, ts.Config.JWT.Aud) + require.NoError(ts.T(), err) + + assert.WithinDuration(ts.T(), time.Now(), *u.EmailChangeSentAt, 1*time.Second) + assert.False(ts.T(), u.IsConfirmed()) + + // Verify new email + reqURL := fmt.Sprintf("http://localhost/verify?type=%s&token=%s", mail.EmailChangeVerification, newTokenHash) + req = httptest.NewRequest(http.MethodGet, reqURL, nil) + + w = httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + + u, err = models.FindUserByEmailAndAudience(ts.API.db, c.currentEmail, ts.Config.JWT.Aud) + require.NoError(ts.T(), err) + assert.Equal(ts.T(), singleConfirmation, u.EmailChangeConfirmStatus) + + // Verify old email + reqURL = fmt.Sprintf("http://localhost/verify?type=%s&token=%s", mail.EmailChangeVerification, currentTokenHash) + req = httptest.NewRequest(http.MethodGet, reqURL, nil) + + w = httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusSeeOther, w.Code) + + // user's email should've been updated to newEmail + u, err = models.FindUserByEmailAndAudience(ts.API.db, c.newEmail, ts.Config.JWT.Aud) + require.NoError(ts.T(), err) + require.Equal(ts.T(), zeroConfirmation, u.EmailChangeConfirmStatus) + + // Assert that email change notification email was sent or not based on the instance's configuration + require.Len(ts.T(), mockMailer.EmailChangedMailCalls, c.expectedNotificationsCalled, fmt.Sprintf("Expected %d email change notification email(s) to be sent", c.expectedNotificationsCalled)) + if c.expectedNotificationsCalled > 0 { + require.Equal(ts.T(), u.ID, mockMailer.EmailChangedMailCalls[0].User.ID, "Email should be sent to the correct user") + require.Equal(ts.T(), currentEmail, mockMailer.EmailChangedMailCalls[0].OldEmail, "Old email should match") + } + + // Reset confirmation status after each test + u.EmailConfirmedAt = nil + require.NoError(ts.T(), ts.API.db.Update(u)) + }) + } +} diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index 72b39b99d..c492be79c 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -392,11 +392,13 @@ type EmailContentConfiguration struct { // Account Changes Notifications PasswordChangedNotification string `json:"password_changed_notification" split_words:"true"` + EmailChangedNotification string `json:"email_changed_notification" split_words:"true"` } // NotificationsConfiguration holds the configuration for notification email states to indicate whether they are enabled or disabled. type NotificationsConfiguration struct { PasswordChangedEnabled bool `json:"password_changed_enabled" split_words:"true" default:"false"` + EmailChangedEnabled bool `json:"email_changed_enabled" split_words:"true" default:"false"` } type ProviderConfiguration struct { diff --git a/internal/mailer/mailer.go b/internal/mailer/mailer.go index ab6f4476d..908034b88 100644 --- a/internal/mailer/mailer.go +++ b/internal/mailer/mailer.go @@ -21,6 +21,7 @@ const ( // Account Changes Notifications PasswordChangedNotification = "password_changed_notification" + EmailChangedNotification = "email_changed_notification" ) // Mailer defines the interface a mailer must implement. @@ -35,6 +36,7 @@ type Mailer interface { // Account Changes Notifications PasswordChangedNotificationMail(r *http.Request, user *models.User) error + EmailChangedNotificationMail(r *http.Request, user *models.User, oldEmail string) error } // TODO(cstockton): Mail(...) -> Mail(Email{...}) ? diff --git a/internal/mailer/mockclient/mockclient.go b/internal/mailer/mockclient/mockclient.go index 20765eab5..df68c06ef 100644 --- a/internal/mailer/mockclient/mockclient.go +++ b/internal/mailer/mockclient/mockclient.go @@ -9,14 +9,16 @@ import ( // MockMailer implements the mailer.Mailer interface for testing type MockMailer struct { - InviteMailCalls []InviteMailCall - ConfirmationMailCalls []ConfirmationMailCall - RecoveryMailCalls []RecoveryMailCall - MagicLinkMailCalls []MagicLinkMailCall - EmailChangeMailCalls []EmailChangeMailCall - ReauthenticateMailCalls []ReauthenticateMailCall - GetEmailActionLinkCalls []GetEmailActionLinkCall + InviteMailCalls []InviteMailCall + ConfirmationMailCalls []ConfirmationMailCall + RecoveryMailCalls []RecoveryMailCall + MagicLinkMailCalls []MagicLinkMailCall + EmailChangeMailCalls []EmailChangeMailCall + ReauthenticateMailCalls []ReauthenticateMailCall + GetEmailActionLinkCalls []GetEmailActionLinkCall + PasswordChangedMailCalls []PasswordChangedMailCall + EmailChangedMailCalls []EmailChangedMailCall } type InviteMailCall struct { @@ -73,6 +75,11 @@ type PasswordChangedMailCall struct { User *models.User } +type EmailChangedMailCall struct { + User *models.User + OldEmail string +} + func (m *MockMailer) InviteMail(r *http.Request, user *models.User, otp, referrerURL string, externalURL *url.URL) error { m.InviteMailCalls = append(m.InviteMailCalls, InviteMailCall{ User: user, @@ -152,6 +159,14 @@ func (m *MockMailer) PasswordChangedNotificationMail(r *http.Request, user *mode return nil } +func (m *MockMailer) EmailChangedNotificationMail(r *http.Request, user *models.User, oldEmail string) error { + m.EmailChangedMailCalls = append(m.EmailChangedMailCalls, EmailChangedMailCall{ + User: user, + OldEmail: oldEmail, + }) + return nil +} + func (m *MockMailer) Reset() { m.InviteMailCalls = nil m.ConfirmationMailCalls = nil @@ -160,5 +175,7 @@ func (m *MockMailer) Reset() { m.EmailChangeMailCalls = nil m.ReauthenticateMailCalls = nil m.GetEmailActionLinkCalls = nil + m.PasswordChangedMailCalls = nil + m.EmailChangedMailCalls = nil } diff --git a/internal/mailer/templatemailer/template.go b/internal/mailer/templatemailer/template.go index cb243178a..847bf188c 100644 --- a/internal/mailer/templatemailer/template.go +++ b/internal/mailer/templatemailer/template.go @@ -537,6 +537,8 @@ func lookupEmailContentConfig( // Account Changes Notifications case PasswordChangedNotificationTemplate: return cfg.PasswordChangedNotification, true + case EmailChangedNotificationTemplate: + return cfg.EmailChangedNotification, true } } diff --git a/internal/mailer/templatemailer/templatemailer.go b/internal/mailer/templatemailer/templatemailer.go index 9a77592d6..eba11e583 100644 --- a/internal/mailer/templatemailer/templatemailer.go +++ b/internal/mailer/templatemailer/templatemailer.go @@ -21,6 +21,7 @@ const ( // Account Changes Notifications PasswordChangedNotificationTemplate = "password_changed_notification" + EmailChangedNotificationTemplate = "email_changed_notification" ) const defaultInviteMail = `

You have been invited

@@ -63,7 +64,13 @@ const defaultReauthenticateMail = `

Confirm reauthentication

// #nosec G101 -- No hardcoded credentials. const defaultPasswordChangedNotificationMail = `

Your password has been changed

-

This is a confirmation that the password for your account {{ .Email }} has just been changed. If you did not make this change, please contact support immediately.

+

This is a confirmation that the password for your account {{ .Email }} has just been changed.

+

If you did not make this change, please contact support.

+` +const defaultEmailChangedNotificationMail = `

Your email address has been changed

+ +

The email address for your account has been changed from {{ .OldEmail }} to {{ .Email }}.

+

If you did not make this change, please contact support.

` var ( @@ -77,6 +84,7 @@ var ( // Account Changes Notifications PasswordChangedNotificationTemplate, + EmailChangedNotificationTemplate, } defaultTemplateSubjects = &conf.EmailContentConfiguration{ Invite: "You have been invited", @@ -99,6 +107,7 @@ var ( // Account Changes Notifications PasswordChangedNotification: defaultPasswordChangedNotificationMail, + EmailChangedNotification: defaultEmailChangedNotificationMail, } ) @@ -365,13 +374,21 @@ func (m *Mailer) GetEmailActionLink(user *models.User, actionType, referrerURL s func (m *Mailer) PasswordChangedNotificationMail(r *http.Request, user *models.User) error { data := map[string]any{ - "Email": user.Email, - "Data": user.UserMetaData, - "SendingTo": user.Email, + "Email": user.Email, + "Data": user.UserMetaData, } return m.mail(r.Context(), m.cfg, PasswordChangedNotificationTemplate, user.GetEmail(), data) } +func (m *Mailer) EmailChangedNotificationMail(r *http.Request, user *models.User, oldEmail string) error { + data := map[string]any{ + "Email": user.GetEmail(), // the new email address that has been set on the account + "OldEmail": oldEmail, // the old email address that was on the account before the change + "Data": user.UserMetaData, + } + return m.mail(r.Context(), m.cfg, EmailChangedNotificationTemplate, oldEmail, data) +} + type emailParams struct { Token string Type string diff --git a/internal/reloader/testdata/50_example.env b/internal/reloader/testdata/50_example.env index 20e496af9..8ce7d9b3f 100644 --- a/internal/reloader/testdata/50_example.env +++ b/internal/reloader/testdata/50_example.env @@ -36,6 +36,7 @@ GOTRUE_MAILER_SUBJECTS_MAGIC_LINK="Your Magic Link" GOTRUE_MAILER_SUBJECTS_EMAIL_CHANGE="Confirm Email Change" GOTRUE_MAILER_SUBJECTS_INVITE="You have been invited" GOTRUE_MAILER_SUBJECTS_PASSWORD_CHANGED_NOTIFICATION="Your password has been changed" +GOTRUE_MAILER_SUBJECTS_EMAIL_CHANGED_NOTIFICATION="Your email address has been changed" GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED="true" # Custom mailer template config @@ -45,9 +46,11 @@ GOTRUE_MAILER_TEMPLATES_RECOVERY="" GOTRUE_MAILER_TEMPLATES_MAGIC_LINK="" GOTRUE_MAILER_TEMPLATES_EMAIL_CHANGE="" GOTRUE_MAILER_TEMPLATES_PASSWORD_CHANGED_NOTIFICATION="" +GOTRUE_MAILER_TEMPLATES_EMAIL_CHANGED_NOTIFICATION="" # Account changes notifications configuration GOTRUE_MAILER_NOTIFICATIONS_PASSWORD_CHANGED_ENABLED="false" +GOTRUE_MAILER_NOTIFICATIONS_EMAIL_CHANGED_ENABLED="false" # Signup config GOTRUE_DISABLE_SIGNUP="false" diff --git a/internal/reloader/testdata/60_example_newline.env b/internal/reloader/testdata/60_example_newline.env index 185199290..3be660093 100644 --- a/internal/reloader/testdata/60_example_newline.env +++ b/internal/reloader/testdata/60_example_newline.env @@ -36,6 +36,7 @@ GOTRUE_MAILER_SUBJECTS_MAGIC_LINK="Your Magic Link" GOTRUE_MAILER_SUBJECTS_EMAIL_CHANGE="Confirm Email Change" GOTRUE_MAILER_SUBJECTS_INVITE="You have been invited" GOTRUE_MAILER_SUBJECTS_PASSWORD_CHANGED_NOTIFICATION="Your password has been changed" +GOTRUE_MAILER_SUBJECTS_EMAIL_CHANGED_NOTIFICATION="Your email address has been changed" GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED="true" # Custom mailer template config @@ -45,9 +46,11 @@ GOTRUE_MAILER_TEMPLATES_RECOVERY="" GOTRUE_MAILER_TEMPLATES_MAGIC_LINK="" GOTRUE_MAILER_TEMPLATES_EMAIL_CHANGE="" GOTRUE_MAILER_TEMPLATES_PASSWORD_CHANGED_NOTIFICATION="" +GOTRUE_MAILER_TEMPLATES_EMAIL_CHANGED_NOTIFICATION="" # Account changes notifications configuration GOTRUE_MAILER_NOTIFICATIONS_PASSWORD_CHANGED_ENABLED="false" +GOTRUE_MAILER_NOTIFICATIONS_EMAIL_CHANGED_ENABLED="false" # Signup config GOTRUE_DISABLE_SIGNUP="false"