Skip to content

Commit 6b382ae

Browse files
authored
feat: add support for account changes notifications in email send hook (#2192)
Adds support for Account Changes Notifications in the `Send Email Hook`.
1 parent 7d46936 commit 6b382ae

File tree

3 files changed

+190
-0
lines changed

3 files changed

+190
-0
lines changed

internal/api/hooks_test.go

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package api
22

33
import (
4+
"encoding/json"
45
"net/http"
6+
"net/url"
57
"testing"
68

79
"net/http/httptest"
@@ -13,6 +15,7 @@ import (
1315
"github.com/supabase/auth/internal/conf"
1416
"github.com/supabase/auth/internal/hooks/hookserrors"
1517
"github.com/supabase/auth/internal/hooks/v0hooks"
18+
mail "github.com/supabase/auth/internal/mailer"
1619
"github.com/supabase/auth/internal/models"
1720
"github.com/supabase/auth/internal/storage"
1821

@@ -294,3 +297,173 @@ func (ts *HooksTestSuite) TestInvokeHookIntegration() {
294297
// Ensure that all expected HTTP interactions (mocks) have been called
295298
require.True(ts.T(), gock.IsDone(), "Expected all mocks to have been called including retry")
296299
}
300+
301+
func (ts *HooksTestSuite) TestAccountChangesNotificationsHookPayload() {
302+
// Setup hook config for send_email hook
303+
defer gock.OffAll()
304+
305+
testURL := "http://localhost:8888/functions/v1/send-email"
306+
ts.Config.Hook.SendEmail.URI = testURL
307+
ts.Config.Hook.SendEmail.Enabled = true
308+
309+
// Mock the hook endpoint to capture the payload
310+
var capturedPayload *v0hooks.SendEmailInput
311+
312+
gock.New(testURL).
313+
Post("/").
314+
MatchType("json").
315+
SetMatcher(gock.NewMatcher()).
316+
AddMatcher(func(req *http.Request, greq *gock.Request) (bool, error) {
317+
// Capture the payload
318+
payload := &v0hooks.SendEmailInput{}
319+
if err := json.NewDecoder(req.Body).Decode(payload); err != nil {
320+
return false, err
321+
}
322+
capturedPayload = payload
323+
return true, nil
324+
}).
325+
Persist().
326+
Reply(http.StatusOK).
327+
JSON(v0hooks.SendEmailOutput{})
328+
329+
testCases := []struct {
330+
description string
331+
expectedActionType string
332+
expectedProvider string
333+
expectedOldEmail string
334+
expectedOldPhone string
335+
expectedFactorType string
336+
setupFunc func() error
337+
enableNotification func()
338+
}{
339+
{
340+
description: "IdentityLinkedNotification contains provider",
341+
expectedActionType: mail.IdentityLinkedNotification,
342+
expectedProvider: "google",
343+
enableNotification: func() {
344+
ts.Config.Mailer.Notifications.IdentityLinkedEnabled = true
345+
},
346+
setupFunc: func() error {
347+
req := httptest.NewRequest("POST", "/identities", nil)
348+
externalHost, err := url.Parse("http://example.com")
349+
require.NoError(ts.T(), err)
350+
req = req.WithContext(withExternalHost(req.Context(), externalHost))
351+
return ts.API.sendIdentityLinkedNotification(req, ts.API.db, ts.TestUser, "google")
352+
},
353+
},
354+
{
355+
description: "IdentityUnlinkedNotification contains provider",
356+
expectedActionType: mail.IdentityUnlinkedNotification,
357+
expectedProvider: "github",
358+
enableNotification: func() {
359+
ts.Config.Mailer.Notifications.IdentityUnlinkedEnabled = true
360+
},
361+
setupFunc: func() error {
362+
req := httptest.NewRequest("DELETE", "/identities/123", nil)
363+
externalHost, err := url.Parse("http://example.com")
364+
require.NoError(ts.T(), err)
365+
req = req.WithContext(withExternalHost(req.Context(), externalHost))
366+
return ts.API.sendIdentityUnlinkedNotification(req, ts.API.db, ts.TestUser, "github")
367+
},
368+
},
369+
{
370+
description: "EmailChangedNotification contains old_email",
371+
expectedActionType: mail.EmailChangedNotification,
372+
expectedOldEmail: "[email protected]",
373+
enableNotification: func() {
374+
ts.Config.Mailer.Notifications.EmailChangedEnabled = true
375+
},
376+
setupFunc: func() error {
377+
req := httptest.NewRequest("PUT", "/user", nil)
378+
externalHost, err := url.Parse("http://example.com")
379+
require.NoError(ts.T(), err)
380+
req = req.WithContext(withExternalHost(req.Context(), externalHost))
381+
return ts.API.sendEmailChangedNotification(req, ts.API.db, ts.TestUser, "[email protected]")
382+
},
383+
},
384+
{
385+
description: "PhoneChangedNotification contains old_phone",
386+
expectedActionType: mail.PhoneChangedNotification,
387+
expectedOldPhone: "+15551234567",
388+
enableNotification: func() {
389+
ts.Config.Mailer.Notifications.PhoneChangedEnabled = true
390+
},
391+
setupFunc: func() error {
392+
req := httptest.NewRequest("PUT", "/user", nil)
393+
externalHost, err := url.Parse("http://example.com")
394+
require.NoError(ts.T(), err)
395+
req = req.WithContext(withExternalHost(req.Context(), externalHost))
396+
return ts.API.sendPhoneChangedNotification(req, ts.API.db, ts.TestUser, "+15551234567")
397+
},
398+
},
399+
{
400+
description: "MFAFactorEnrolledNotification contains factor_type",
401+
expectedActionType: mail.MFAFactorEnrolledNotification,
402+
expectedFactorType: "totp",
403+
enableNotification: func() {
404+
ts.Config.Mailer.Notifications.MFAFactorEnrolledEnabled = true
405+
},
406+
setupFunc: func() error {
407+
req := httptest.NewRequest("POST", "/factors", nil)
408+
externalHost, err := url.Parse("http://example.com")
409+
require.NoError(ts.T(), err)
410+
req = req.WithContext(withExternalHost(req.Context(), externalHost))
411+
return ts.API.sendMFAFactorEnrolledNotification(req, ts.API.db, ts.TestUser, "totp")
412+
},
413+
},
414+
{
415+
description: "MFAFactorUnenrolledNotification contains factor_type",
416+
expectedActionType: mail.MFAFactorUnenrolledNotification,
417+
expectedFactorType: "phone",
418+
enableNotification: func() {
419+
ts.Config.Mailer.Notifications.MFAFactorUnenrolledEnabled = true
420+
},
421+
setupFunc: func() error {
422+
req := httptest.NewRequest("DELETE", "/factors/123", nil)
423+
externalHost, err := url.Parse("http://example.com")
424+
require.NoError(ts.T(), err)
425+
req = req.WithContext(withExternalHost(req.Context(), externalHost))
426+
return ts.API.sendMFAFactorUnenrolledNotification(req, ts.API.db, ts.TestUser, "phone")
427+
},
428+
},
429+
}
430+
431+
for _, tc := range testCases {
432+
ts.Run(tc.description, func() {
433+
// Reset captured payload
434+
capturedPayload = nil
435+
436+
// Enable the notification
437+
tc.enableNotification()
438+
439+
// Execute the setup function that triggers the notification
440+
err := tc.setupFunc()
441+
require.NoError(ts.T(), err)
442+
443+
// Verify the payload was captured
444+
require.NotNil(ts.T(), capturedPayload, "Hook should have been called")
445+
446+
// Verify email action type
447+
require.Equal(ts.T(), tc.expectedActionType, capturedPayload.EmailData.EmailActionType)
448+
449+
// Verify notification-specific fields
450+
if tc.expectedProvider != "" {
451+
require.Equal(ts.T(), tc.expectedProvider, capturedPayload.EmailData.Provider, "Provider should be set in EmailData")
452+
}
453+
if tc.expectedOldEmail != "" {
454+
require.Equal(ts.T(), tc.expectedOldEmail, capturedPayload.EmailData.OldEmail, "OldEmail should be set in EmailData")
455+
}
456+
if tc.expectedOldPhone != "" {
457+
require.Equal(ts.T(), tc.expectedOldPhone, capturedPayload.EmailData.OldPhone, "OldPhone should be set in EmailData")
458+
}
459+
if tc.expectedFactorType != "" {
460+
require.Equal(ts.T(), tc.expectedFactorType, capturedPayload.EmailData.FactorType, "FactorType should be set in EmailData")
461+
}
462+
463+
// Verify common fields
464+
require.Equal(ts.T(), ts.TestUser.ID, capturedPayload.User.ID, "User ID should match")
465+
require.NotEmpty(ts.T(), capturedPayload.EmailData.SiteURL, "SiteURL should be set")
466+
require.NotEmpty(ts.T(), capturedPayload.EmailData.RedirectTo, "RedirectTo should be set")
467+
})
468+
}
469+
}

internal/api/mail.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -844,6 +844,19 @@ func (a *API) sendEmail(r *http.Request, tx *storage.Connection, u *models.User,
844844
emailData.Token = params.otpNew
845845
}
846846
}
847+
848+
// Augment the email data for the email send hook with notification-specific fields
849+
switch params.emailActionType {
850+
case mail.EmailChangedNotification:
851+
emailData.OldEmail = params.oldEmail
852+
case mail.PhoneChangedNotification:
853+
emailData.OldPhone = params.oldPhone
854+
case mail.IdentityLinkedNotification, mail.IdentityUnlinkedNotification:
855+
emailData.Provider = params.provider
856+
case mail.MFAFactorEnrolledNotification, mail.MFAFactorUnenrolledNotification:
857+
emailData.FactorType = params.factorType
858+
}
859+
847860
input := v0hooks.SendEmailInput{
848861
User: u,
849862
EmailData: emailData,

internal/mailer/mailer.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,4 +69,8 @@ type EmailData struct {
6969
SiteURL string `json:"site_url"`
7070
TokenNew string `json:"token_new"`
7171
TokenHashNew string `json:"token_hash_new"`
72+
OldEmail string `json:"old_email"`
73+
OldPhone string `json:"old_phone"`
74+
Provider string `json:"provider"`
75+
FactorType string `json:"factor_type"`
7276
}

0 commit comments

Comments
 (0)