diff --git a/backend/internal/flow/common/constants.go b/backend/internal/flow/common/constants.go index 46ba3d7be4..8fafd174ef 100644 --- a/backend/internal/flow/common/constants.go +++ b/backend/internal/flow/common/constants.go @@ -193,6 +193,8 @@ const ( RuntimeKeyUserAttributesCacheTTLSeconds = "user_attributes_cache_ttl_seconds" // RuntimeKeyInviteLink holds the generated invite link for downstream executors (e.g., EmailExecutor). RuntimeKeyInviteLink = "inviteLink" + // RuntimeKeySkipDelivery indicates that delivery should be skipped for the current flow. + RuntimeKeySkipDelivery = "skipDelivery" // RuntimeKeyCandidateUsers holds serialized candidate users during disambiguation in resolve mode. RuntimeKeyCandidateUsers = "candidateUsers" ) @@ -203,6 +205,8 @@ const ( const ( // InputTypeText represents a text input type. InputTypeText = "TEXT_INPUT" + // InputTypeEmail represents an email input type. + InputTypeEmail = "EMAIL_INPUT" // InputTypePassword represents a password credential input type. InputTypePassword = "PASSWORD_INPUT" // InputTypeOTP represents a one-time password input type. diff --git a/backend/internal/flow/executor/email_executor.go b/backend/internal/flow/executor/email_executor.go index 14ef176323..7c1f8fa5d2 100644 --- a/backend/internal/flow/executor/email_executor.go +++ b/backend/internal/flow/executor/email_executor.go @@ -22,6 +22,7 @@ import ( "errors" "fmt" + "github.com/asgardeo/thunder/internal/entityprovider" "github.com/asgardeo/thunder/internal/flow/common" "github.com/asgardeo/thunder/internal/flow/core" "github.com/asgardeo/thunder/internal/system/email" @@ -36,18 +37,28 @@ type emailExecutor struct { logger *log.Logger emailClient email.EmailClientInterface templateService template.TemplateServiceInterface + entityProvider entityprovider.EntityProviderInterface +} + +// defaultEmailInput is the default input definition for email collection. +var defaultEmailInput = common.Input{ + Ref: "email_input", + Identifier: userAttributeEmail, + Type: common.InputTypeEmail, + Required: true, } // newEmailExecutor creates a new instance of the email executor. func newEmailExecutor(flowFactory core.FlowFactoryInterface, emailClient email.EmailClientInterface, - templateService template.TemplateServiceInterface) *emailExecutor { + templateService template.TemplateServiceInterface, + entityProvider entityprovider.EntityProviderInterface) *emailExecutor { logger := log.GetLogger().With(log.String(log.LoggerKeyComponentName, "EmailExecutor")) base := flowFactory.CreateExecutor( ExecutorNameEmailExecutor, common.ExecutorTypeUtility, []common.Input{}, []common.Input{ - {Identifier: userAttributeEmail, Type: common.InputTypeText, Required: true}, + defaultEmailInput, }, ) return &emailExecutor{ @@ -55,6 +66,7 @@ func newEmailExecutor(flowFactory core.FlowFactoryInterface, emailClient email.E logger: logger, emailClient: emailClient, templateService: templateService, + entityProvider: entityProvider, } } @@ -79,6 +91,12 @@ func (e *emailExecutor) executeSend(ctx *core.NodeContext) (*common.ExecutorResp RuntimeData: make(map[string]string), } + if ctx.RuntimeData[common.RuntimeKeySkipDelivery] == dataValueTrue { + logger.Debug("Delivery marked as skipped, completing without sending email") + execResp.Status = common.ExecComplete + return execResp, nil + } + if e.emailClient == nil { execResp.AdditionalData[common.DataEmailSent] = dataValueFalse execResp.Status = common.ExecFailure @@ -92,7 +110,10 @@ func (e *emailExecutor) executeSend(ctx *core.NodeContext) (*common.ExecutorResp } // Resolve recipient email from user inputs or runtime data. - recipient := resolveRecipientEmail(ctx) + recipient, err := e.resolveRecipientEmail(ctx, logger) + if err != nil { + return nil, err + } if recipient == "" { logger.Debug("Email recipient not found in user inputs or runtime data") execResp.Status = common.ExecFailure @@ -125,7 +146,6 @@ func (e *emailExecutor) executeSend(ctx *core.NodeContext) (*common.ExecutorResp "inviteLink": inviteLink, "appName": ctx.Application.Name, } - rendered, svcErr := e.templateService.Render(ctx.Context, scenario, template.TemplateTypeEmail, templateData) if svcErr != nil { return nil, fmt.Errorf("failed to render email template: %s", svcErr.Code) @@ -156,15 +176,41 @@ func (e *emailExecutor) executeSend(ctx *core.NodeContext) (*common.ExecutorResp return execResp, nil } -// resolveRecipientEmail retrieves the recipient email from user inputs or runtime data. -func resolveRecipientEmail(ctx *core.NodeContext) string { - if recipientEmail, ok := ctx.UserInputs[userAttributeEmail]; ok && recipientEmail != "" { - return recipientEmail +// resolveRecipientEmail retrieves the recipient email from user inputs, runtime data, or forwarded data. +func (e *emailExecutor) resolveRecipientEmail(ctx *core.NodeContext, logger *log.Logger) (string, error) { + emailAttr := e.resolveEmailInput(ctx).Identifier + + if recipientEmail, ok := ctx.ForwardedData[emailAttr]; ok { + if emailStr, isString := recipientEmail.(string); isString && emailStr != "" { + return emailStr, nil + } } - if recipientEmail, ok := ctx.RuntimeData[userAttributeEmail]; ok && recipientEmail != "" { - return recipientEmail + if recipientEmail, ok := ctx.RuntimeData[emailAttr]; ok && recipientEmail != "" { + return recipientEmail, nil } - return "" + if recipientEmail, ok := ctx.UserInputs[emailAttr]; ok && recipientEmail != "" { + return recipientEmail, nil + } + + if userID, ok := ctx.RuntimeData[userAttributeUserID]; ok && userID != "" { + if e.entityProvider == nil { + return "", errors.New("entity provider is not configured for email resolution") + } + user, providerErr := e.entityProvider.GetEntity(userID) + if providerErr != nil { + if providerErr.Code == entityprovider.ErrorCodeEntityNotFound { + return "", nil + } + return "", fmt.Errorf("failed to fetch user from entity provider: %w", providerErr) + } + + if recipientEmail, err := GetUserAttribute(user, emailAttr); err == nil { + return recipientEmail, nil + } + logger.Debug("Email attribute not found in user entity", log.String("attribute", emailAttr)) + } + + return "", nil } // isEmailError returns true if the error originated from the email subsystem, @@ -180,3 +226,14 @@ func isEmailError(err error) bool { errors.Is(err, email.ErrorSMTPAuth) || errors.Is(err, email.ErrorEmailSendFailed) } + +// resolveEmailInput returns the EMAIL_INPUT definition from the node context inputs, +// falling back to the default if none is found. +func (e *emailExecutor) resolveEmailInput(ctx *core.NodeContext) common.Input { + for _, input := range ctx.NodeInputs { + if input.Type == common.InputTypeEmail { + return input + } + } + return defaultEmailInput +} diff --git a/backend/internal/flow/executor/email_executor_test.go b/backend/internal/flow/executor/email_executor_test.go index 53f88bae94..d2b447a411 100644 --- a/backend/internal/flow/executor/email_executor_test.go +++ b/backend/internal/flow/executor/email_executor_test.go @@ -22,15 +22,16 @@ import ( "fmt" "testing" - "github.com/stretchr/testify/mock" "github.com/stretchr/testify/suite" + "github.com/asgardeo/thunder/internal/entityprovider" "github.com/asgardeo/thunder/internal/flow/common" "github.com/asgardeo/thunder/internal/flow/core" "github.com/asgardeo/thunder/internal/system/email" "github.com/asgardeo/thunder/internal/system/error/serviceerror" "github.com/asgardeo/thunder/internal/system/template" "github.com/asgardeo/thunder/tests/mocks/emailmock" + "github.com/asgardeo/thunder/tests/mocks/entityprovidermock" "github.com/asgardeo/thunder/tests/mocks/flow/coremock" "github.com/asgardeo/thunder/tests/mocks/templatemock" ) @@ -40,6 +41,7 @@ type EmailExecutorTestSuite struct { mockFlowFactory *coremock.FlowFactoryInterfaceMock mockEmailClient *emailmock.EmailClientInterfaceMock mockTemplateService *templatemock.TemplateServiceInterfaceMock + mockEntityProvider *entityprovidermock.EntityProviderInterfaceMock executor *emailExecutor } @@ -48,17 +50,23 @@ func (suite *EmailExecutorTestSuite) SetupTest() { mockBaseExecutor := coremock.NewExecutorInterfaceMock(suite.T()) suite.mockEmailClient = emailmock.NewEmailClientInterfaceMock(suite.T()) suite.mockTemplateService = templatemock.NewTemplateServiceInterfaceMock(suite.T()) + suite.mockEntityProvider = entityprovidermock.NewEntityProviderInterfaceMock(suite.T()) suite.mockFlowFactory.On("CreateExecutor", ExecutorNameEmailExecutor, common.ExecutorTypeUtility, []common.Input{}, []common.Input{ - {Identifier: userAttributeEmail, Type: common.InputTypeText, Required: true}, + defaultEmailInput, }, ).Return(mockBaseExecutor) - suite.executor = newEmailExecutor(suite.mockFlowFactory, suite.mockEmailClient, suite.mockTemplateService) + suite.executor = newEmailExecutor( + suite.mockFlowFactory, + suite.mockEmailClient, + suite.mockTemplateService, + suite.mockEntityProvider, + ) } func (suite *EmailExecutorTestSuite) TestExecute_SendMode_UserInviteTemplate_Success() { @@ -78,7 +86,7 @@ func (suite *EmailExecutorTestSuite) TestExecute_SendMode_UserInviteTemplate_Suc } suite.mockTemplateService.On("Render", - mock.Anything, + ctx.Context, template.ScenarioUserInvite, template.TemplateTypeEmail, template.TemplateData{ @@ -91,20 +99,19 @@ func (suite *EmailExecutorTestSuite) TestExecute_SendMode_UserInviteTemplate_Suc IsHTML: true, }, nil) - var sentEmail email.EmailData - suite.mockEmailClient.On("Send", mock.Anything).Run(func(args mock.Arguments) { - sentEmail = args.Get(0).(email.EmailData) - }).Return(nil) + expectedEmail := email.EmailData{ + To: []string{"user@example.com"}, + Subject: "You're Invited to Register", + Body: "Complete Registration", + IsHTML: true, + } + suite.mockEmailClient.On("Send", expectedEmail).Return(nil) resp, err := suite.executor.Execute(ctx) suite.NoError(err) - suite.Equal(common.ExecComplete, resp.Status) + suite.Equal(common.ExecComplete, resp.Status, "FailureReason: "+resp.FailureReason) suite.Equal(dataValueTrue, resp.AdditionalData[common.DataEmailSent]) - suite.Equal([]string{"user@example.com"}, sentEmail.To) - suite.Equal("You're Invited to Register", sentEmail.Subject) - suite.True(sentEmail.IsHTML) - suite.Contains(sentEmail.Body, "Complete Registration") } func (suite *EmailExecutorTestSuite) TestExecute_SendMode_SelfRegistration_InviteLinkNotExposed() { @@ -124,7 +131,7 @@ func (suite *EmailExecutorTestSuite) TestExecute_SendMode_SelfRegistration_Invit } suite.mockTemplateService.On("Render", - mock.Anything, + ctx.Context, template.ScenarioSelfRegistration, template.TemplateTypeEmail, template.TemplateData{ @@ -137,18 +144,24 @@ func (suite *EmailExecutorTestSuite) TestExecute_SendMode_SelfRegistration_Invit IsHTML: true, }, nil) - suite.mockEmailClient.On("Send", mock.Anything).Return(nil) + expectedEmail := email.EmailData{ + To: []string{"user@example.com"}, + Subject: "Complete Your Registration", + Body: "Click to register", + IsHTML: true, + } + suite.mockEmailClient.On("Send", expectedEmail).Return(nil) resp, err := suite.executor.Execute(ctx) suite.NoError(err) - suite.Equal(common.ExecComplete, resp.Status) + suite.Equal(common.ExecComplete, resp.Status, "FailureReason: "+resp.FailureReason) suite.Equal(dataValueTrue, resp.AdditionalData[common.DataEmailSent]) // For SELF_REGISTRATION, invite link must NOT be exposed in AdditionalData suite.Empty(resp.AdditionalData[common.DataInviteLink]) } -func (suite *EmailExecutorTestSuite) TestExecute_SendMode_UsesUserInputOverRuntimeRecipient() { +func (suite *EmailExecutorTestSuite) TestExecute_SendMode_UsesRuntimeRecipientOverUserInput() { ctx := &core.NodeContext{ ExecutionID: "test-execution-id", ExecutorMode: ExecutorModeSend, @@ -165,7 +178,7 @@ func (suite *EmailExecutorTestSuite) TestExecute_SendMode_UsesUserInputOverRunti } suite.mockTemplateService.On("Render", - mock.Anything, + ctx.Context, template.ScenarioUserInvite, template.TemplateTypeEmail, template.TemplateData{ @@ -178,16 +191,18 @@ func (suite *EmailExecutorTestSuite) TestExecute_SendMode_UsesUserInputOverRunti IsHTML: true, }, nil) - var sentEmail email.EmailData - suite.mockEmailClient.On("Send", mock.Anything).Run(func(args mock.Arguments) { - sentEmail = args.Get(0).(email.EmailData) - }).Return(nil) + expectedEmail := email.EmailData{ + To: []string{"runtime@example.com"}, + Subject: "You're Invited to Register", + Body: "Complete Registration", + IsHTML: true, + } + suite.mockEmailClient.On("Send", expectedEmail).Return(nil) resp, err := suite.executor.Execute(ctx) suite.NoError(err) - suite.Equal(common.ExecComplete, resp.Status) - suite.Equal([]string{"user@example.com"}, sentEmail.To) + suite.Equal(common.ExecComplete, resp.Status, "FailureReason: "+resp.FailureReason) } func (suite *EmailExecutorTestSuite) TestExecute_SendMode_EmailFromRuntimeData() { @@ -205,7 +220,7 @@ func (suite *EmailExecutorTestSuite) TestExecute_SendMode_EmailFromRuntimeData() } suite.mockTemplateService.On("Render", - mock.Anything, + ctx.Context, template.ScenarioUserInvite, template.TemplateTypeEmail, template.TemplateData{ @@ -218,16 +233,18 @@ func (suite *EmailExecutorTestSuite) TestExecute_SendMode_EmailFromRuntimeData() IsHTML: true, }, nil) - var sentEmail email.EmailData - suite.mockEmailClient.On("Send", mock.Anything).Run(func(args mock.Arguments) { - sentEmail = args.Get(0).(email.EmailData) - }).Return(nil) + expectedEmail := email.EmailData{ + To: []string{"runtime@example.com"}, + Subject: "You're Invited to Register", + Body: "Complete Registration", + IsHTML: true, + } + suite.mockEmailClient.On("Send", expectedEmail).Return(nil) resp, err := suite.executor.Execute(ctx) suite.NoError(err) - suite.Equal(common.ExecComplete, resp.Status) - suite.Equal([]string{"runtime@example.com"}, sentEmail.To) + suite.Equal(common.ExecComplete, resp.Status, "FailureReason: "+resp.FailureReason) } func (suite *EmailExecutorTestSuite) TestExecute_SendMode_MissingRecipient() { @@ -248,7 +265,7 @@ func (suite *EmailExecutorTestSuite) TestExecute_SendMode_MissingRecipient() { suite.NoError(err) suite.Equal(common.ExecFailure, resp.Status) suite.Equal("Email recipient is required", resp.FailureReason) - suite.mockEmailClient.AssertNotCalled(suite.T(), "Send", mock.Anything) + suite.mockEmailClient.AssertNumberOfCalls(suite.T(), "Send", 0) } func (suite *EmailExecutorTestSuite) TestExecute_SendMode_MissingInviteLink() { @@ -269,7 +286,7 @@ func (suite *EmailExecutorTestSuite) TestExecute_SendMode_MissingInviteLink() { suite.Error(err) suite.Nil(resp) suite.Contains(err.Error(), "invite link not found") - suite.mockEmailClient.AssertNotCalled(suite.T(), "Send", mock.Anything) + suite.mockEmailClient.AssertNumberOfCalls(suite.T(), "Send", 0) } func (suite *EmailExecutorTestSuite) TestExecute_SendMode_SelfRegistration_MissingInviteLink() { @@ -291,7 +308,7 @@ func (suite *EmailExecutorTestSuite) TestExecute_SendMode_SelfRegistration_Missi suite.Error(err) suite.Nil(resp) suite.Contains(err.Error(), "invite link not found") - suite.mockEmailClient.AssertNotCalled(suite.T(), "Send", mock.Anything) + suite.mockEmailClient.AssertNumberOfCalls(suite.T(), "Send", 0) } func (suite *EmailExecutorTestSuite) TestExecute_SendMode_MissingTemplateProperty_DefaultsToUserInvite() { @@ -309,7 +326,7 @@ func (suite *EmailExecutorTestSuite) TestExecute_SendMode_MissingTemplatePropert // Verify that Render is called with ScenarioUserInvite even when the property is absent. suite.mockTemplateService.On("Render", - mock.Anything, + ctx.Context, template.ScenarioUserInvite, template.TemplateTypeEmail, template.TemplateData{ @@ -322,16 +339,18 @@ func (suite *EmailExecutorTestSuite) TestExecute_SendMode_MissingTemplatePropert IsHTML: true, }, nil) - var sentEmail email.EmailData - suite.mockEmailClient.On("Send", mock.Anything).Run(func(args mock.Arguments) { - sentEmail = args.Get(0).(email.EmailData) - }).Return(nil) + expectedEmail := email.EmailData{ + To: []string{"user@example.com"}, + Subject: "You're Invited to Register", + Body: "Complete Registration", + IsHTML: true, + } + suite.mockEmailClient.On("Send", expectedEmail).Return(nil) resp, err := suite.executor.Execute(ctx) suite.NoError(err) - suite.Equal(common.ExecComplete, resp.Status) - suite.Equal([]string{"user@example.com"}, sentEmail.To) + suite.Equal(common.ExecComplete, resp.Status, "FailureReason: "+resp.FailureReason) } func (suite *EmailExecutorTestSuite) TestExecute_SendMode_EmptyTemplateString_DefaultsToUserInvite() { @@ -350,7 +369,7 @@ func (suite *EmailExecutorTestSuite) TestExecute_SendMode_EmptyTemplateString_De } suite.mockTemplateService.On("Render", - mock.Anything, + ctx.Context, template.ScenarioUserInvite, template.TemplateTypeEmail, template.TemplateData{ @@ -363,16 +382,18 @@ func (suite *EmailExecutorTestSuite) TestExecute_SendMode_EmptyTemplateString_De IsHTML: true, }, nil) - var sentEmail email.EmailData - suite.mockEmailClient.On("Send", mock.Anything).Run(func(args mock.Arguments) { - sentEmail = args.Get(0).(email.EmailData) - }).Return(nil) + expectedEmail := email.EmailData{ + To: []string{"user@example.com"}, + Subject: "You're Invited to Register", + Body: "Complete Registration", + IsHTML: true, + } + suite.mockEmailClient.On("Send", expectedEmail).Return(nil) resp, err := suite.executor.Execute(ctx) suite.NoError(err) - suite.Equal(common.ExecComplete, resp.Status) - suite.Equal([]string{"user@example.com"}, sentEmail.To) + suite.Equal(common.ExecComplete, resp.Status, "FailureReason: "+resp.FailureReason) } func (suite *EmailExecutorTestSuite) TestExecute_SendMode_InvalidTemplateType_ReturnsError() { @@ -413,10 +434,13 @@ func (suite *EmailExecutorTestSuite) TestExecute_SendMode_TemplateRenderError() } suite.mockTemplateService.On("Render", - mock.Anything, + ctx.Context, template.ScenarioUserInvite, template.TemplateTypeEmail, - mock.Anything, + template.TemplateData{ + "inviteLink": "https://localhost:5190/gate/invite?executionId=test&inviteToken=abc", + "appName": "", + }, ).Return(nil, &serviceerror.ServiceError{Code: "TMP-5000"}) resp, err := suite.executor.Execute(ctx) @@ -424,7 +448,7 @@ func (suite *EmailExecutorTestSuite) TestExecute_SendMode_TemplateRenderError() suite.Contains(err.Error(), "failed to render email template: TMP-5000") } suite.Nil(resp) - suite.mockEmailClient.AssertNotCalled(suite.T(), "Send", mock.Anything) + suite.mockEmailClient.AssertNumberOfCalls(suite.T(), "Send", 0) } func (suite *EmailExecutorTestSuite) TestExecute_SendMode_NilTemplateService() { @@ -435,11 +459,11 @@ func (suite *EmailExecutorTestSuite) TestExecute_SendMode_NilTemplateService() { common.ExecutorTypeUtility, []common.Input{}, []common.Input{ - {Identifier: userAttributeEmail, Type: common.InputTypeText, Required: true}, + defaultEmailInput, }, ).Return(mockBaseExecutor) - noServiceExecutor := newEmailExecutor(mockFactory, suite.mockEmailClient, nil) + noServiceExecutor := newEmailExecutor(mockFactory, suite.mockEmailClient, nil, suite.mockEntityProvider) ctx := &core.NodeContext{ ExecutionID: "test-execution-id", @@ -478,17 +502,26 @@ func (suite *EmailExecutorTestSuite) TestExecute_SendMode_ClientError() { } suite.mockTemplateService.On("Render", - mock.Anything, + ctx.Context, template.ScenarioUserInvite, template.TemplateTypeEmail, - mock.Anything, + template.TemplateData{ + "inviteLink": "https://localhost:5190/gate/invite?executionId=test&inviteToken=abc", + "appName": "", + }, ).Return(&template.RenderedTemplate{ Subject: "You're Invited to Register", Body: "Complete Registration", IsHTML: true, }, nil) - suite.mockEmailClient.On("Send", mock.Anything).Return(email.ErrorInvalidRecipient) + expectedEmail := email.EmailData{ + To: []string{"user@example.com"}, + Subject: "You're Invited to Register", + Body: "Complete Registration", + IsHTML: true, + } + suite.mockEmailClient.On("Send", expectedEmail).Return(email.ErrorInvalidRecipient) resp, err := suite.executor.Execute(ctx) @@ -526,17 +559,26 @@ func (suite *EmailExecutorTestSuite) TestExecute_SendMode_KnownSMTPErrors() { } suite.mockTemplateService.On("Render", - mock.Anything, + ctx.Context, template.ScenarioUserInvite, template.TemplateTypeEmail, - mock.Anything, + template.TemplateData{ + "inviteLink": "https://localhost:5190/gate/invite?executionId=test&inviteToken=abc", + "appName": "", + }, ).Return(&template.RenderedTemplate{ Subject: "You're Invited to Register", Body: "Complete Registration", IsHTML: true, }, nil) - suite.mockEmailClient.On("Send", mock.Anything).Return(tc.sendErr) + expectedEmail := email.EmailData{ + To: []string{"user@example.com"}, + Subject: "You're Invited to Register", + Body: "Complete Registration", + IsHTML: true, + } + suite.mockEmailClient.On("Send", expectedEmail).Return(tc.sendErr) resp, err := suite.executor.Execute(ctx) @@ -564,17 +606,26 @@ func (suite *EmailExecutorTestSuite) TestExecute_SendMode_UnexpectedError() { } suite.mockTemplateService.On("Render", - mock.Anything, + ctx.Context, template.ScenarioUserInvite, template.TemplateTypeEmail, - mock.Anything, + template.TemplateData{ + "inviteLink": "https://localhost:5190/gate/invite?executionId=test&inviteToken=abc", + "appName": "", + }, ).Return(&template.RenderedTemplate{ Subject: "You're Invited to Register", Body: "Complete Registration", IsHTML: true, }, nil) - suite.mockEmailClient.On("Send", mock.Anything).Return(fmt.Errorf("unexpected internal error")) + expectedEmail := email.EmailData{ + To: []string{"user@example.com"}, + Subject: "You're Invited to Register", + Body: "Complete Registration", + IsHTML: true, + } + suite.mockEmailClient.On("Send", expectedEmail).Return(fmt.Errorf("unexpected internal error")) resp, err := suite.executor.Execute(ctx) @@ -590,11 +641,11 @@ func (suite *EmailExecutorTestSuite) TestExecute_SendMode_NilEmailClient_Returns common.ExecutorTypeUtility, []common.Input{}, []common.Input{ - {Identifier: userAttributeEmail, Type: common.InputTypeText, Required: true}, + defaultEmailInput, }, ).Return(mockBaseExecutor) - noEmailExecutor := newEmailExecutor(mockFactory, nil, suite.mockTemplateService) + noEmailExecutor := newEmailExecutor(mockFactory, nil, suite.mockTemplateService, suite.mockEntityProvider) ctx := &core.NodeContext{ ExecutionID: "test-execution-id", @@ -637,3 +688,284 @@ func (suite *EmailExecutorTestSuite) TestExecute_InvalidMode() { func TestEmailExecutorSuite(t *testing.T) { suite.Run(t, new(EmailExecutorTestSuite)) } + +func (suite *EmailExecutorTestSuite) TestExecute_SendMode_ResolvesEmailFromForwardedData() { + ctx := &core.NodeContext{ + ExecutionID: "test-execution-id", + ExecutorMode: ExecutorModeSend, + ForwardedData: map[string]interface{}{ + userAttributeEmail: "forwarded@example.com", + }, + RuntimeData: map[string]string{ + common.RuntimeKeyInviteLink: "https://localhost:5190/gate/invite?executionId=test&inviteToken=abc", + }, + NodeProperties: map[string]interface{}{ + "emailTemplate": "USER_INVITE", + }, + } + + suite.mockTemplateService.On("Render", + ctx.Context, + template.ScenarioUserInvite, + template.TemplateTypeEmail, + template.TemplateData{ + "inviteLink": "https://localhost:5190/gate/invite?executionId=test&inviteToken=abc", + "appName": "", + }, + ).Return(&template.RenderedTemplate{ + Subject: "You're Invited to Register", + Body: "Complete Registration", + IsHTML: true, + }, nil) + + expectedEmail := email.EmailData{ + To: []string{"forwarded@example.com"}, + Subject: "You're Invited to Register", + Body: "Complete Registration", + IsHTML: true, + } + suite.mockEmailClient.On("Send", expectedEmail).Return(nil) + + resp, err := suite.executor.Execute(ctx) + + suite.NoError(err) + suite.Equal(common.ExecComplete, resp.Status) +} + +func (suite *EmailExecutorTestSuite) TestExecute_SendMode_ResolvesEmailUsingConfiguredInputIdentifier() { + ctx := &core.NodeContext{ + ExecutionID: "test-execution-id", + ExecutorMode: ExecutorModeSend, + NodeInputs: []common.Input{ + {Identifier: "workEmail", Type: common.InputTypeEmail, Required: true}, + }, + UserInputs: map[string]string{ + "workEmail": "configured@example.com", + }, + RuntimeData: map[string]string{ + common.RuntimeKeyInviteLink: "https://localhost:5190/gate/invite?executionId=test&inviteToken=abc", + }, + NodeProperties: map[string]interface{}{ + "emailTemplate": "USER_INVITE", + }, + } + + suite.mockTemplateService.On("Render", + ctx.Context, + template.ScenarioUserInvite, + template.TemplateTypeEmail, + template.TemplateData{ + "inviteLink": "https://localhost:5190/gate/invite?executionId=test&inviteToken=abc", + "appName": "", + }, + ).Return(&template.RenderedTemplate{ + Subject: "You're Invited to Register", + Body: "Complete Registration", + IsHTML: true, + }, nil) + + expectedEmail := email.EmailData{ + To: []string{"configured@example.com"}, + Subject: "You're Invited to Register", + Body: "Complete Registration", + IsHTML: true, + } + suite.mockEmailClient.On("Send", expectedEmail).Return(nil) + + resp, err := suite.executor.Execute(ctx) + + suite.NoError(err) + suite.Equal(common.ExecComplete, resp.Status) +} + +func (suite *EmailExecutorTestSuite) TestExecute_SendMode_ResolvesEmailFromEntityProvider() { + ctx := &core.NodeContext{ + ExecutionID: "test-execution-id", + ExecutorMode: ExecutorModeSend, + NodeInputs: []common.Input{ + {Identifier: "workEmail", Type: common.InputTypeEmail, Required: true}, + }, + RuntimeData: map[string]string{ + userAttributeUserID: "test-db-user-id", + common.RuntimeKeyInviteLink: "https://localhost:5190/gate/invite?executionId=test&inviteToken=abc", + }, + NodeProperties: map[string]interface{}{ + "emailTemplate": "USER_INVITE", + }, + } + + mockEntity := &entityprovider.Entity{ + ID: "test-db-user-id", + Attributes: []byte(`{"workEmail":"database-resolved@example.com"}`), + } + suite.mockEntityProvider.On("GetEntity", "test-db-user-id").Return(mockEntity, nil) + + suite.mockTemplateService.On("Render", + ctx.Context, + template.ScenarioUserInvite, + template.TemplateTypeEmail, + template.TemplateData{ + "inviteLink": "https://localhost:5190/gate/invite?executionId=test&inviteToken=abc", + "appName": "", + }, + ).Return(&template.RenderedTemplate{ + Subject: "You're Invited to Register", + Body: "Complete Registration", + IsHTML: true, + }, nil) + + expectedEmail := email.EmailData{ + To: []string{"database-resolved@example.com"}, + Subject: "You're Invited to Register", + Body: "Complete Registration", + IsHTML: true, + } + suite.mockEmailClient.On("Send", expectedEmail).Return(nil) + + resp, err := suite.executor.Execute(ctx) + + suite.NoError(err) + suite.Equal(common.ExecComplete, resp.Status) +} + +func (suite *EmailExecutorTestSuite) TestExecute_SendMode_ForwardedDataInvalidType() { + ctx := &core.NodeContext{ + ExecutionID: "test-execution-id", + ExecutorMode: ExecutorModeSend, + ForwardedData: map[string]interface{}{ + userAttributeEmail: 12345, // invalid type + }, + RuntimeData: map[string]string{ + common.RuntimeKeyInviteLink: "https://localhost:5190/gate/invite?executionId=test&inviteToken=abc", + }, + NodeProperties: map[string]interface{}{ + "emailTemplate": "USER_INVITE", + }, + } + + resp, err := suite.executor.Execute(ctx) + + suite.NoError(err) + suite.Equal(common.ExecFailure, resp.Status) + suite.Equal("Email recipient is required", resp.FailureReason) + suite.mockEmailClient.AssertNumberOfCalls(suite.T(), "Send", 0) +} + +func (suite *EmailExecutorTestSuite) TestExecute_SendMode_EntityProviderMissingEmailAttribute() { + ctx := &core.NodeContext{ + ExecutionID: "test-execution-id", + ExecutorMode: ExecutorModeSend, + NodeInputs: []common.Input{ + {Identifier: "workEmail", Type: common.InputTypeEmail, Required: true}, + }, + RuntimeData: map[string]string{ + userAttributeUserID: "test-db-user-id", + common.RuntimeKeyInviteLink: "https://localhost:5190/gate/invite?executionId=test&inviteToken=abc", + }, + NodeProperties: map[string]interface{}{ + "emailTemplate": "USER_INVITE", + }, + } + + mockEntity := &entityprovider.Entity{ + ID: "test-db-user-id", + Attributes: []byte(`{"other":"data"}`), + } + suite.mockEntityProvider.On("GetEntity", "test-db-user-id").Return(mockEntity, nil) + + resp, err := suite.executor.Execute(ctx) + + suite.NoError(err) + suite.Equal(common.ExecFailure, resp.Status) + suite.Equal("Email recipient is required", resp.FailureReason) + suite.mockEmailClient.AssertNumberOfCalls(suite.T(), "Send", 0) +} + +func (suite *EmailExecutorTestSuite) TestExecute_SendMode_SkipDelivery() { + ctx := &core.NodeContext{ + ExecutionID: "test-execution-id", + ExecutorMode: ExecutorModeSend, + RuntimeData: map[string]string{ + common.RuntimeKeySkipDelivery: dataValueTrue, + }, + } + + resp, err := suite.executor.Execute(ctx) + + suite.NoError(err) + suite.Equal(common.ExecComplete, resp.Status) + suite.mockEmailClient.AssertNumberOfCalls(suite.T(), "Send", 0) +} + +func (suite *EmailExecutorTestSuite) TestExecute_SendMode_EntityProviderError() { + ctx := &core.NodeContext{ + ExecutionID: "test-execution-id", + ExecutorMode: ExecutorModeSend, + RuntimeData: map[string]string{ + userAttributeUserID: "test-user-id", + }, + } + + suite.mockEntityProvider.On("GetEntity", "test-user-id").Return( + nil, entityprovider.NewEntityProviderError( + entityprovider.ErrorCodeSystemError, "provider error", "system failure")) + + resp, err := suite.executor.Execute(ctx) + + suite.Error(err) + suite.Nil(resp) + suite.Contains(err.Error(), "failed to fetch user from entity provider") + suite.mockEmailClient.AssertNumberOfCalls(suite.T(), "Send", 0) +} + +func (suite *EmailExecutorTestSuite) TestExecute_SendMode_EntityProviderUserNotFound() { + ctx := &core.NodeContext{ + ExecutionID: "test-execution-id", + ExecutorMode: ExecutorModeSend, + RuntimeData: map[string]string{ + userAttributeUserID: "non-existent-user-id", + }, + } + + suite.mockEntityProvider.On("GetEntity", "non-existent-user-id").Return( + nil, entityprovider.NewEntityProviderError( + entityprovider.ErrorCodeEntityNotFound, "user not found", "entity not found")) + + resp, err := suite.executor.Execute(ctx) + + suite.NoError(err) + suite.Equal(common.ExecFailure, resp.Status) + suite.Equal("Email recipient is required", resp.FailureReason) + suite.mockEmailClient.AssertNumberOfCalls(suite.T(), "Send", 0) +} + +func (suite *EmailExecutorTestSuite) TestExecute_SendMode_NilEntityProvider_ReturnsError() { + mockBaseExecutor := coremock.NewExecutorInterfaceMock(suite.T()) + mockFactory := coremock.NewFlowFactoryInterfaceMock(suite.T()) + mockFactory.On("CreateExecutor", + ExecutorNameEmailExecutor, + common.ExecutorTypeUtility, + []common.Input{}, + []common.Input{ + defaultEmailInput, + }, + ).Return(mockBaseExecutor) + + // Create executor with nil entity provider + noProviderExecutor := newEmailExecutor(mockFactory, suite.mockEmailClient, suite.mockTemplateService, nil) + + ctx := &core.NodeContext{ + ExecutionID: "test-execution-id", + ExecutorMode: ExecutorModeSend, + RuntimeData: map[string]string{ + userAttributeUserID: "test-user-id", + }, + } + + resp, err := noProviderExecutor.Execute(ctx) + + suite.Error(err) + suite.Nil(resp) + suite.Contains(err.Error(), "entity provider is not configured for email resolution") + suite.mockEmailClient.AssertNumberOfCalls(suite.T(), "Send", 0) +} diff --git a/backend/internal/flow/executor/init.go b/backend/internal/flow/executor/init.go index 0c98213872..2a5226cea4 100644 --- a/backend/internal/flow/executor/init.go +++ b/backend/internal/flow/executor/init.go @@ -103,7 +103,8 @@ func Initialize( reg.RegisterExecutor(ExecutorNameHTTPRequest, newHTTPRequestExecutor(flowFactory, ouService)) reg.RegisterExecutor(ExecutorNameUserTypeResolver, newUserTypeResolver(flowFactory, userSchemaService, ouService)) reg.RegisterExecutor(ExecutorNameInviteExecutor, newInviteExecutor(flowFactory)) - reg.RegisterExecutor(ExecutorNameEmailExecutor, newEmailExecutor(flowFactory, emailClient, templateService)) + reg.RegisterExecutor(ExecutorNameEmailExecutor, newEmailExecutor( + flowFactory, emailClient, templateService, entityProvider)) reg.RegisterExecutor(ExecutorNameCredentialSetter, newCredentialSetter(flowFactory, entityProvider)) reg.RegisterExecutor(ExecutorNamePermissionValidator, newPermissionValidator(flowFactory)) reg.RegisterExecutor(ExecutorNameIdentifying, newIdentifyingExecutor( diff --git a/backend/internal/flow/executor/utils.go b/backend/internal/flow/executor/utils.go index e4eb127d23..cf628ac489 100644 --- a/backend/internal/flow/executor/utils.go +++ b/backend/internal/flow/executor/utils.go @@ -19,7 +19,12 @@ package executor import ( + "encoding/json" + "errors" + "fmt" + authncm "github.com/asgardeo/thunder/internal/authn/common" + "github.com/asgardeo/thunder/internal/entityprovider" ) // getAuthnServiceName returns the authn service name for an executor. @@ -35,3 +40,23 @@ func getAuthnServiceName(executorName string) string { } return executorToAuthnServiceMap[executorName] } + +// GetUserAttribute extracts a specific attribute value from a user entity's JSON attributes. +func GetUserAttribute(user *entityprovider.Entity, attributeKey string) (string, error) { + if user == nil || len(user.Attributes) == 0 { + return "", errors.New("user entity or attributes are empty") + } + + var attrs map[string]interface{} + if err := json.Unmarshal(user.Attributes, &attrs); err != nil { + return "", errors.New("failed to parse user attributes") + } + + if val, ok := attrs[attributeKey]; ok { + if strVal, isString := val.(string); isString && strVal != "" { + return strVal, nil + } + } + + return "", fmt.Errorf("attribute '%s' not found or is empty", attributeKey) +} diff --git a/backend/internal/flow/executor/utils_test.go b/backend/internal/flow/executor/utils_test.go index 660fc925aa..ceb0f4fc5d 100644 --- a/backend/internal/flow/executor/utils_test.go +++ b/backend/internal/flow/executor/utils_test.go @@ -25,6 +25,7 @@ import ( "github.com/stretchr/testify/suite" authncm "github.com/asgardeo/thunder/internal/authn/common" + "github.com/asgardeo/thunder/internal/entityprovider" "github.com/asgardeo/thunder/internal/flow/common" "github.com/asgardeo/thunder/internal/flow/core" "github.com/asgardeo/thunder/tests/mocks/flow/coremock" @@ -85,3 +86,74 @@ func createMockAuthExecutor(t *testing.T, executorName string) core.ExecutorInte }).Maybe() return mockExec } + +func (s *UtilsTestSuite) TestGetUserAttribute() { + tests := []struct { + name string + user *entityprovider.Entity + attributeKey string + expectedVal string + expectError bool + }{ + { + name: "Success case", + user: &entityprovider.Entity{ + Attributes: []byte(`{"email":"user@example.com"}`), + }, + attributeKey: "email", + expectedVal: "user@example.com", + expectError: false, + }, + { + name: "Nil user", + user: nil, + attributeKey: "email", + expectError: true, + }, + { + name: "Empty attributes", + user: &entityprovider.Entity{ + Attributes: []byte(``), + }, + attributeKey: "email", + expectError: true, + }, + { + name: "Invalid JSON attributes", + user: &entityprovider.Entity{ + Attributes: []byte(`invalid-json`), + }, + attributeKey: "email", + expectError: true, + }, + { + name: "Attribute not found", + user: &entityprovider.Entity{ + Attributes: []byte(`{"other":"data"}`), + }, + attributeKey: "email", + expectError: true, + }, + { + name: "Non-string attribute value", + user: &entityprovider.Entity{ + Attributes: []byte(`{"email":123}`), + }, + attributeKey: "email", + expectError: true, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + val, err := GetUserAttribute(tt.user, tt.attributeKey) + if tt.expectError { + s.Error(err) + s.Empty(val) + } else { + s.NoError(err) + s.Equal(tt.expectedVal, val) + } + }) + } +} diff --git a/docs/content/guides/guides/flows/flow-reference.mdx b/docs/content/guides/guides/flows/flow-reference.mdx index 1b71ff847d..36d2c32d77 100644 --- a/docs/content/guides/guides/flows/flow-reference.mdx +++ b/docs/content/guides/guides/flows/flow-reference.mdx @@ -88,6 +88,7 @@ Executors are backend operation nodes. Each Executor performs one specific opera | **Start Passkey Registration** | Begins the passkey registration ceremony. | | **Finish Passkey Registration** | Completes the passkey registration ceremony. | | **Auth Assertion Generator** | Generates the final authentication assertion when login succeeds. | +| **Email Executor** | Sends email for invitation and registration flows. Supports `skipDelivery` and falls back to the user entity when the recipient is missing from the flow context. | | **Provisioning** | Creates or updates the user record in the store. | | **Attribute Collector** | Collects additional user attributes defined in the user schema. | | **Authorization** | Evaluates authorization policies for the current user. | @@ -119,6 +120,7 @@ Some Executors run entirely in the background and do not need a preceding View t | **Auth Assertion Generator** | Last Executor before the END node in every authentication flow | User must be authenticated by prior Executors | | **Authorization** | After authentication Executors, before Auth Assertion Generator | User must be authenticated | | **Send SMS OTP** | Before the SMS OTP View | User's mobile number must be on record | +| **Email Executor** | After the flow collects the recipient email | Recipient email input; `inviteLink` for invite and self-registration templates | | **Provisioning** | After identity collection in registration flows | — | | **Identity Resolver** | Early in the flow, after the user submits an identifier | — | | **User Type Resolver** | After Identity Resolver | — |