diff --git a/internal/ent/hooks/invite.go b/internal/ent/hooks/invite.go index 1560067bca..e44805ac0d 100644 --- a/internal/ent/hooks/invite.go +++ b/internal/ent/hooks/invite.go @@ -102,12 +102,23 @@ func HookInvite() ent.Hook { return retValue, ErrInternalServerError } + // check if the recipient already has an account so the invite link can route accordingly + recipientExists, err := m.Client().User.Query(). + Where(user.EmailEqualFold(emailAddress)). + Exist(privacy.DecisionContext(ctx, privacy.Allow)) + if err != nil { + logx.FromContext(ctx).Error().Err(err).Msg("error checking recipient account existence") + + recipientExists = true + } + if err := sendSystemEmail(ctx, m.Client(), emaildef.InviteOp.Name(), emaildef.InviteRequest{ RecipientInfo: emaildef.RecipientInfo{Email: emailAddress}, InviterName: inviterName, OrgName: orgName, Role: string(role), Token: tokenValue, + NewUser: !recipientExists, }); err != nil { logx.FromContext(ctx).Error().Err(err).Msg("error sending email to user") diff --git a/internal/integrations/definitions/email/system_emails.go b/internal/integrations/definitions/email/system_emails.go index 3052c62a0b..8c20e882a0 100644 --- a/internal/integrations/definitions/email/system_emails.go +++ b/internal/integrations/definitions/email/system_emails.go @@ -99,6 +99,8 @@ type InviteRequest struct { Role string `json:"role,omitempty" jsonschema:"description=Invited role"` // Token is the invite token appended to the invite URL Token string `json:"token" jsonschema:"required,description=Invite token"` + // NewUser indicates the recipient has no existing account + NewUser bool `json:"newUser,omitempty" jsonschema:"description=Whether the recipient has no existing account"` } // InviteJoinedRequest is the input for the invite-accepted notification @@ -320,7 +322,11 @@ var _ = RegisterEmailOperation(Operation[InviteRequest]{ return "Join Your Teammate " + req.InviterName + " on " + cfg.CompanyName + "!" }, Build: func(cfg RuntimeEmailConfig, req InviteRequest) render.ContentBody { - inviteURL := tokenURL(cfg.ProductURL, "/invite", req.Token) + // append the recipient email and new-account hint so the invite page can route the recipient + inviteURL := tokenURL(cfg.ProductURL, "/invite", req.Token) + "&email=" + url.QueryEscape(req.Email) + if req.NewUser { + inviteURL += "&new=true" + } inviteIntro := template.HTML("You're in - let's build trust without the busywork. "+html.EscapeString(req.InviterName)+" has invited you to collaborate in "+html.EscapeString(cfg.CompanyName)+", as part of the ") + //nolint:gosec // all user input is escaped via html.EscapeString render.Bold(req.OrgName) + " organization" if req.Role != "" { diff --git a/internal/integrations/definitions/email/system_emails_test.go b/internal/integrations/definitions/email/system_emails_test.go index b109819507..63d3f9d9f3 100644 --- a/internal/integrations/definitions/email/system_emails_test.go +++ b/internal/integrations/definitions/email/system_emails_test.go @@ -185,7 +185,7 @@ func TestInviteEmailURLConstruction(t *testing.T) { } req := InviteRequest{ - RecipientInfo: RecipientInfo{FirstName: "Bob"}, + RecipientInfo: RecipientInfo{FirstName: "Bob", Email: "bob@example.com"}, InviterName: "Alice", OrgName: "Engineering", Role: "admin", @@ -195,7 +195,7 @@ func TestInviteEmailURLConstruction(t *testing.T) { body := testDispatcher[InviteRequest](t, "InviteRequest").Build(cfg, req) require.Len(t, body.Actions, 1) - assert.Equal(t, "https://app.testco.com/invite?token=inv-tok-456", body.Actions[0].Button.Link) + assert.Equal(t, "https://app.testco.com/invite?token=inv-tok-456&email=bob%40example.com", body.Actions[0].Button.Link) assert.Equal(t, "Accept Invite", body.Actions[0].Button.Text) }