diff --git a/api4/team.go b/api4/team.go index 5ef022b541..cf353f2b8d 100644 --- a/api4/team.go +++ b/api4/team.go @@ -1275,7 +1275,18 @@ func inviteUsersToTeam(c *Context, w http.ResponseWriter, r *http.Request) { return } - emailList := model.ArrayFromJSON(r.Body) + bf, err := io.ReadAll(r.Body) + if err != nil { + c.Err = model.NewAppError("Api4.inviteUsersToTeams", "api.team.invite_members_to_team_and_channels.invalid_body.app_error", nil, err.Error(), http.StatusBadRequest) + return + } + memberInvite := &model.MemberInvite{} + if jsonErr := json.Unmarshal(bf, memberInvite); jsonErr != nil { + c.Err = model.NewAppError("Api4.inviteUsersToTeams", "api.team.invite_members_to_team_and_channels.invalid_body_parsing.app_error", nil, jsonErr.Error(), http.StatusBadRequest) + return + } + + emailList := memberInvite.Emails for i := range emailList { emailList[i] = strings.ToLower(emailList[i]) @@ -1292,11 +1303,16 @@ func inviteUsersToTeam(c *Context, w http.ResponseWriter, r *http.Request) { auditRec.AddMeta("count", len(emailList)) auditRec.AddMeta("emails", emailList) + if len(memberInvite.ChannelIds) > 0 { + auditRec.AddMeta("channel_count", len(memberInvite.ChannelIds)) + auditRec.AddMeta("channels", memberInvite.ChannelIds) + } + if graceful { var invitesWithError []*model.EmailInviteWithError var err *model.AppError if emailList != nil { - invitesWithError, err = c.App.InviteNewUsersToTeamGracefully(emailList, c.Params.TeamId, c.AppContext.Session().UserId, "") + invitesWithError, err = c.App.InviteNewUsersToTeamGracefully(memberInvite, c.Params.TeamId, c.AppContext.Session().UserId, "") } if invitesWithError != nil { @@ -1322,6 +1338,10 @@ func inviteUsersToTeam(c *Context, w http.ResponseWriter, r *http.Request) { "scheduledAt": strconv.FormatInt(scheduledAt, 10), } + if len(memberInvite.ChannelIds) > 0 { + jobData["channelList"] = model.ArrayToJSON(memberInvite.ChannelIds) + } + // we then manually schedule the job to send another invite after 48 hours _, e := c.App.Srv().Jobs.CreateJob(model.JobTypeResendInvitationEmail, jobData) if e != nil { diff --git a/api4/team_local.go b/api4/team_local.go index 296c8058b7..991c537cd1 100644 --- a/api4/team_local.go +++ b/api4/team_local.go @@ -6,6 +6,7 @@ package api4 import ( "encoding/json" "fmt" + "io" "net/http" "strings" @@ -76,7 +77,19 @@ func localInviteUsersToTeam(c *Context, w http.ResponseWriter, r *http.Request) return } - emailList := model.ArrayFromJSON(r.Body) + bf, err := io.ReadAll(r.Body) + if err != nil { + c.Err = model.NewAppError("Api4.inviteUsersToTeams", "api.team.invite_members_to_team_and_channels.invalid_body.app_error", nil, err.Error(), http.StatusBadRequest) + return + } + memberInvite := &model.MemberInvite{} + if jsonErr := json.Unmarshal(bf, memberInvite); jsonErr != nil { + c.Err = model.NewAppError("Api4.inviteUsersToTeams", "api.team.invite_members_to_team_and_channels.invalid_body_parsing.app_error", nil, jsonErr.Error(), http.StatusBadRequest) + return + } + + emailList := memberInvite.Emails + if len(emailList) == 0 { c.SetInvalidParam("user_email") return @@ -96,6 +109,11 @@ func localInviteUsersToTeam(c *Context, w http.ResponseWriter, r *http.Request) auditRec.AddMeta("count", len(emailList)) auditRec.AddMeta("emails", emailList) + if len(memberInvite.ChannelIds) > 0 { + auditRec.AddMeta("channel_count", len(memberInvite.ChannelIds)) + auditRec.AddMeta("channels", memberInvite.ChannelIds) + } + team, nErr := c.App.Srv().Store.Team().Get(c.Params.TeamId) if nErr != nil { var nfErr *store.ErrNotFound @@ -110,6 +128,14 @@ func localInviteUsersToTeam(c *Context, w http.ResponseWriter, r *http.Request) allowedDomains := []string{team.AllowedDomains, *c.App.Config().TeamSettings.RestrictCreationToDomains} + var channels []*model.Channel + if len(memberInvite.ChannelIds) > 0 { + channels, err = c.App.Srv().Store.Channel().GetChannelsByIds(memberInvite.ChannelIds, false) + if err != nil { + c.Err = model.NewAppError("prepareLocalInviteNewUsersToTeam", "app.channel.get_channels_by_ids.app_error", nil, err.Error(), http.StatusInternalServerError) + } + } + if r.URL.Query().Get("graceful") != "" { var invitesWithErrors []*model.EmailInviteWithError var goodEmails, errList []string @@ -128,8 +154,16 @@ func localInviteUsersToTeam(c *Context, w http.ResponseWriter, r *http.Request) } auditRec.AddMeta("errors", errList) if len(goodEmails) > 0 { - err := c.App.Srv().EmailService.SendInviteEmails(team, "Administrator", "mmctl "+model.NewId(), goodEmails, *c.App.Config().ServiceSettings.SiteURL, nil, false) - if err != nil { + var eErr error + var invitesWithErrors2 []*model.EmailInviteWithError + if len(channels) > 0 { + invitesWithErrors2, eErr = c.App.Srv().EmailService.SendInviteEmailsToTeamAndChannels(team, channels, "Administrator", "mmctl "+model.NewId(), nil, goodEmails, *c.App.Config().ServiceSettings.SiteURL, nil, memberInvite.Message, true) + invitesWithErrors = append(invitesWithErrors, invitesWithErrors2...) + } else { + eErr = c.App.Srv().EmailService.SendInviteEmails(team, "Administrator", "mmctl "+model.NewId(), goodEmails, *c.App.Config().ServiceSettings.SiteURL, nil, false) + } + + if eErr != nil { switch { case errors.Is(err, email.NoRateLimiterError): c.Err = model.NewAppError("SendInviteEmails", "app.email.no_rate_limiter.app_error", nil, fmt.Sprintf("team_id=%s", team.Id), http.StatusInternalServerError) diff --git a/api4/team_test.go b/api4/team_test.go index e4518b5802..9ec7fc747e 100644 --- a/api4/team_test.go +++ b/api4/team_test.go @@ -2982,7 +2982,8 @@ func TestInviteUsersToTeam(t *testing.T) { user1 := th.GenerateTestEmail() user2 := th.GenerateTestEmail() - emailList := []string{user1, user2} + memberInvite := &model.MemberInvite{Emails: []string{user1, user2}} + emailList := memberInvite.Emails //Delete all the messages before check the sample email mail.DeleteMailBox(user1) @@ -3018,7 +3019,7 @@ func TestInviteUsersToTeam(t *testing.T) { require.True(t, strings.ContainsAny(resultsMailbox[len(resultsMailbox)-1].To[0], email), "Wrong To recipient") resultsEmail, err := mail.GetMessageFromMailbox(email, resultsMailbox[len(resultsMailbox)-1].ID) if err == nil { - require.Equalf(t, resultsEmail.Subject, expectedSubject, "Wrong Subject, actual: %s, expected: %s", resultsEmail.Subject, expectedSubject) + require.Equalf(t, resultsEmail.Subject, expectedSubject, "Wrong Subject, \nactual: %s, \nexpected: %s", resultsEmail.Subject, expectedSubject) } } } @@ -3034,6 +3035,18 @@ func TestInviteUsersToTeam(t *testing.T) { "SiteName": th.App.ClientConfig()["SiteName"]}) checkEmail(t, expectedSubject) + // Test the invite to team and channel + mail.DeleteMailBox(user1) + mail.DeleteMailBox(user2) + _, _, err = th.SystemAdminClient.InviteUsersToTeamAndChannelsGracefully(th.BasicTeam.Id, []string{user1, user2}, []string{th.BasicChannel.Id}, "") + require.NoError(t, err) + expectedSubject = i18n.T("api.templates.invite_team_and_channel_subject", + map[string]interface{}{"SenderName": th.SystemAdminUser.GetDisplayName(nameFormat), + "TeamDisplayName": th.BasicTeam.DisplayName, + "ChannelName": th.BasicChannel.DisplayName, + "SiteName": th.App.ClientConfig()["SiteName"]}) + checkEmail(t, expectedSubject) + mail.DeleteMailBox(user1) mail.DeleteMailBox(user2) _, err = th.LocalClient.InviteUsersToTeam(th.BasicTeam.Id, emailList) @@ -3044,6 +3057,18 @@ func TestInviteUsersToTeam(t *testing.T) { "SiteName": th.App.ClientConfig()["SiteName"]}) checkEmail(t, expectedSubject) + // Test the invite local to team and channel + mail.DeleteMailBox(user1) + mail.DeleteMailBox(user2) + _, _, err = th.LocalClient.InviteUsersToTeamAndChannelsGracefully(th.BasicTeam.Id, []string{user1, user2}, []string{th.BasicChannel.Id}, "") + require.NoError(t, err) + expectedSubject = i18n.T("api.templates.invite_team_and_channel_subject", + map[string]interface{}{"SenderName": "Administrator", + "TeamDisplayName": th.BasicTeam.DisplayName, + "ChannelName": th.BasicChannel.DisplayName, + "SiteName": th.App.ClientConfig()["SiteName"]}) + checkEmail(t, expectedSubject) + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.TeamSettings.RestrictCreationToDomains = "@global.com,@common.com" }) th.TestForAllClients(t, func(t *testing.T, client *model.Client4) { diff --git a/app/app_iface.go b/app/app_iface.go index 6066cea4d4..31dcb25a3a 100644 --- a/app/app_iface.go +++ b/app/app_iface.go @@ -817,7 +817,7 @@ type AppIface interface { InviteGuestsToChannels(teamID string, guestsInvite *model.GuestsInvite, senderId string) *model.AppError InviteGuestsToChannelsGracefully(teamID string, guestsInvite *model.GuestsInvite, senderId string) ([]*model.EmailInviteWithError, *model.AppError) InviteNewUsersToTeam(emailList []string, teamID, senderId string) *model.AppError - InviteNewUsersToTeamGracefully(emailList []string, teamID, senderId string, reminderInterval string) ([]*model.EmailInviteWithError, *model.AppError) + InviteNewUsersToTeamGracefully(memberInvite *model.MemberInvite, teamID, senderId string, reminderInterval string) ([]*model.EmailInviteWithError, *model.AppError) IsCRTEnabledForUser(userID string) bool IsFirstUserAccount() bool IsLeader() bool diff --git a/app/email/email.go b/app/email/email.go index f164404606..2237590a83 100644 --- a/app/email/email.go +++ b/app/email/email.go @@ -606,6 +606,153 @@ func (es *Service) SendGuestInviteEmails(team *model.Team, channels []*model.Cha return nil } +func (es *Service) SendInviteEmailsToTeamAndChannels( + team *model.Team, + channels []*model.Channel, + senderName string, + senderUserId string, + senderProfileImage []byte, + invites []string, + siteURL string, + reminderData *model.TeamInviteReminderData, + message string, + errorWhenNotSent bool, +) ([]*model.EmailInviteWithError, error) { + if es.perHourEmailRateLimiter == nil { + return nil, NoRateLimiterError + } + rateLimited, result, err := es.perHourEmailRateLimiter.RateLimit(senderUserId, len(invites)) + if err != nil { + return nil, SetupRateLimiterError + } + + if rateLimited { + mlog.Error("rate limit exceeded", mlog.Duration("RetryAfter", result.RetryAfter), mlog.Duration("ResetAfter", result.ResetAfter), mlog.String("user_id", senderUserId), + mlog.String("team_id", team.Id), mlog.String("retry_after_secs", fmt.Sprintf("%f", result.RetryAfter.Seconds())), mlog.String("reset_after_secs", fmt.Sprintf("%f", result.ResetAfter.Seconds()))) + return nil, RateLimitExceededError + } + + channelsLen := len(channels) + + subject := i18n.T("api.templates.invite_team_and_channels_subject", map[string]interface{}{ + "SenderName": senderName, + "TeamDisplayName": team.DisplayName, + "ChannelsLen": channelsLen, + "SiteName": es.config().TeamSettings.SiteName}) + + title := i18n.T("api.templates.invite_team_and_channels_body.title", map[string]interface{}{ + "SenderName": senderName, + "ChannelsLen": channelsLen, + "TeamDisplayName": team.DisplayName}) + + if channelsLen == 1 { + channelName := channels[0].DisplayName + + subject = i18n.T("api.templates.invite_team_and_channel_subject", + map[string]interface{}{"SenderName": senderName, + "TeamDisplayName": team.DisplayName, + "ChannelName": channelName, + "SiteName": es.config().TeamSettings.SiteName}, + ) + + title = i18n.T("api.templates.invite_team_and_channel_body.title", map[string]interface{}{ + "SenderName": senderName, + "ChannelName": channelName, + "TeamDisplayName": team.DisplayName, + }) + } + + var invitesWithErrors []*model.EmailInviteWithError + for _, invite := range invites { + if invite == "" { + continue + } + channelIDs := []string{} + for _, channel := range channels { + channelIDs = append(channelIDs, channel.Id) + } + + data := es.NewEmailTemplateData("") + data.Props["SiteURL"] = siteURL + data.Props["SubTitle"] = i18n.T("api.templates.invite_body.subTitle") + data.Props["Button"] = i18n.T("api.templates.invite_body.button") + data.Props["SenderName"] = senderName + data.Props["InviteFooterTitle"] = i18n.T("api.templates.invite_body_footer.title") + data.Props["InviteFooterInfo"] = i18n.T("api.templates.invite_body_footer.info") + data.Props["InviteFooterLearnMore"] = i18n.T("api.templates.invite_body_footer.learn_more") + + if message != "" { + message = bluemonday.NewPolicy().Sanitize(message) + } + data.Props["Message"] = message + + token := model.NewToken( + TokenTypeTeamInvitation, + model.MapToJSON(map[string]string{ + "teamId": team.Id, + "email": invite, + "channels": strings.Join(channelIDs, " "), + }), + ) + + tokenProps := make(map[string]string) + tokenProps["email"] = invite + tokenProps["display_name"] = team.DisplayName + tokenProps["name"] = team.Name + + if reminderData != nil { + reminder := i18n.T("api.templates.invite_body.title.reminder") + title = fmt.Sprintf("%s: %s", reminder, title) + tokenProps["reminder_interval"] = reminderData.Interval + } + + data.Props["Title"] = title + + tokenData := model.MapToJSON(tokenProps) + + if err := es.store.Token().Save(token); err != nil { + mlog.Error("Failed to send invite email successfully ", mlog.Err(err)) + continue + } + data.Props["ButtonURL"] = fmt.Sprintf("%s/signup_user_complete/?d=%s&t=%s", siteURL, url.QueryEscape(tokenData), url.QueryEscape(token.Token)) + + senderPhoto := "" + embeddedFiles := make(map[string]io.Reader) + if message != "" { + if senderProfileImage != nil { + senderPhoto = "user-avatar.png" + embeddedFiles = map[string]io.Reader{ + senderPhoto: bytes.NewReader(senderProfileImage), + } + } + } + pData := postData{ + SenderName: senderName, + Message: template.HTML(message), + SenderPhoto: senderPhoto, + } + + data.Props["Posts"] = []postData{pData} + + body, err := es.templatesContainer.RenderToString("invite_body", data) + if err != nil { + mlog.Error("Failed to send invite email successfully ", mlog.Err(err)) + } + + if nErr := es.SendMailWithEmbeddedFiles(invite, subject, body, embeddedFiles); nErr != nil { + mlog.Error("Failed to send invite email successfully", mlog.Err(nErr)) + if errorWhenNotSent { + inviteWithError := &model.EmailInviteWithError{ + Email: invite, + Error: &model.AppError{Message: nErr.Error()}, + } + invitesWithErrors = append(invitesWithErrors, inviteWithError) + } + } + } + return invitesWithErrors, nil +} + func (es *Service) NewEmailTemplateData(locale string) templates.Data { var localT i18n.TranslateFunc if locale != "" { diff --git a/app/email/mocks/ServiceInterface.go b/app/email/mocks/ServiceInterface.go index bc98b8a48c..af7761a2e8 100644 --- a/app/email/mocks/ServiceInterface.go +++ b/app/email/mocks/ServiceInterface.go @@ -237,6 +237,29 @@ func (_m *ServiceInterface) SendInviteEmails(team *model.Team, senderName string return r0 } +// SendInviteEmailsToTeamAndChannels provides a mock function with given fields: team, channels, senderName, senderUserId, senderProfileImage, invites, siteURL, reminderData, message, errorWhenNotSent +func (_m *ServiceInterface) SendInviteEmailsToTeamAndChannels(team *model.Team, channels []*model.Channel, senderName string, senderUserId string, senderProfileImage []byte, invites []string, siteURL string, reminderData *model.TeamInviteReminderData, message string, errorWhenNotSent bool) ([]*model.EmailInviteWithError, error) { + ret := _m.Called(team, channels, senderName, senderUserId, senderProfileImage, invites, siteURL, reminderData, message, errorWhenNotSent) + + var r0 []*model.EmailInviteWithError + if rf, ok := ret.Get(0).(func(*model.Team, []*model.Channel, string, string, []byte, []string, string, *model.TeamInviteReminderData, string, bool) []*model.EmailInviteWithError); ok { + r0 = rf(team, channels, senderName, senderUserId, senderProfileImage, invites, siteURL, reminderData, message, errorWhenNotSent) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.EmailInviteWithError) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(*model.Team, []*model.Channel, string, string, []byte, []string, string, *model.TeamInviteReminderData, string, bool) error); ok { + r1 = rf(team, channels, senderName, senderUserId, senderProfileImage, invites, siteURL, reminderData, message, errorWhenNotSent) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // SendLicenseInactivityEmail provides a mock function with given fields: _a0, name, locale, siteURL func (_m *ServiceInterface) SendLicenseInactivityEmail(_a0 string, name string, locale string, siteURL string) error { ret := _m.Called(_a0, name, locale, siteURL) diff --git a/app/email/service.go b/app/email/service.go index eb9789e173..f78821a706 100644 --- a/app/email/service.go +++ b/app/email/service.go @@ -138,6 +138,7 @@ type ServiceInterface interface { SendMfaChangeEmail(email string, activated bool, locale, siteURL string) error SendInviteEmails(team *model.Team, senderName string, senderUserId string, invites []string, siteURL string, reminderData *model.TeamInviteReminderData, errorWhenNotSent bool) error SendGuestInviteEmails(team *model.Team, channels []*model.Channel, senderName string, senderUserId string, senderProfileImage []byte, invites []string, siteURL string, message string, errorWhenNotSent bool) error + SendInviteEmailsToTeamAndChannels(team *model.Team, channels []*model.Channel, senderName string, senderUserId string, senderProfileImage []byte, invites []string, siteURL string, reminderData *model.TeamInviteReminderData, message string, errorWhenNotSent bool) ([]*model.EmailInviteWithError, error) SendDeactivateAccountEmail(email string, locale, siteURL string) error SendNotificationMail(to, subject, htmlBody string) error SendMailWithEmbeddedFiles(to, subject, htmlBody string, embeddedFiles map[string]io.Reader) error diff --git a/app/email_test.go b/app/email_test.go index ead2cd76eb..f022c96c8e 100644 --- a/app/email_test.go +++ b/app/email_test.go @@ -25,16 +25,17 @@ func TestSendInviteEmailRateLimits(t *testing.T) { *cfg.ServiceSettings.EnableEmailInvitations = true }) - emailList := make([]string, 22) + memberInvite := &model.MemberInvite{} + memberInvite.Emails = make([]string, 22) for i := 0; i < 22; i++ { - emailList[i] = "test-" + strconv.Itoa(i) + "@common.com" + memberInvite.Emails[i] = "test-" + strconv.Itoa(i) + "@common.com" } - err = th.App.InviteNewUsersToTeam(emailList, th.BasicTeam.Id, th.BasicUser.Id) + err = th.App.InviteNewUsersToTeam(memberInvite.Emails, th.BasicTeam.Id, th.BasicUser.Id) require.NotNil(t, err) assert.Equal(t, "app.email.rate_limit_exceeded.app_error", err.Id) assert.Equal(t, http.StatusRequestEntityTooLarge, err.StatusCode) - _, err = th.App.InviteNewUsersToTeamGracefully(emailList, th.BasicTeam.Id, th.BasicUser.Id, "") + _, err = th.App.InviteNewUsersToTeamGracefully(memberInvite, th.BasicTeam.Id, th.BasicUser.Id, "") require.NotNil(t, err) assert.Equal(t, "app.email.rate_limit_exceeded.app_error", err.Id) assert.Equal(t, http.StatusRequestEntityTooLarge, err.StatusCode) diff --git a/app/opentracing/opentracing_layer.go b/app/opentracing/opentracing_layer.go index fb5cac3b24..127e55a593 100644 --- a/app/opentracing/opentracing_layer.go +++ b/app/opentracing/opentracing_layer.go @@ -10966,7 +10966,7 @@ func (a *OpenTracingAppLayer) InviteNewUsersToTeam(emailList []string, teamID st return resultVar0 } -func (a *OpenTracingAppLayer) InviteNewUsersToTeamGracefully(emailList []string, teamID string, senderId string, reminderInterval string) ([]*model.EmailInviteWithError, *model.AppError) { +func (a *OpenTracingAppLayer) InviteNewUsersToTeamGracefully(memberInvite *model.MemberInvite, teamID string, senderId string, reminderInterval string) ([]*model.EmailInviteWithError, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.InviteNewUsersToTeamGracefully") @@ -10978,7 +10978,7 @@ func (a *OpenTracingAppLayer) InviteNewUsersToTeamGracefully(emailList []string, }() defer span.Finish() - resultVar0, resultVar1 := a.app.InviteNewUsersToTeamGracefully(emailList, teamID, senderId, reminderInterval) + resultVar0, resultVar1 := a.app.InviteNewUsersToTeamGracefully(memberInvite, teamID, senderId, reminderInterval) if resultVar1 != nil { span.LogFields(spanlog.Error(resultVar1)) diff --git a/app/team.go b/app/team.go index 0e2adbc393..9d5be5e432 100644 --- a/app/team.go +++ b/app/team.go @@ -1207,7 +1207,7 @@ func (a *App) postRemoveFromTeamMessage(c *request.Context, user *model.User, ch return nil } -func (a *App) prepareInviteNewUsersToTeam(teamID, senderId string) (*model.User, *model.Team, *model.AppError) { +func (a *App) prepareInviteNewUsersToTeam(teamID, senderId string, channelIds []string) (*model.User, *model.Team, []*model.Channel, *model.AppError) { tchan := make(chan store.StoreResult, 1) go func() { team, err := a.Srv().Store.Team().Get(teamID) @@ -1222,14 +1222,22 @@ func (a *App) prepareInviteNewUsersToTeam(teamID, senderId string) (*model.User, close(uchan) }() + var channels []*model.Channel + var err error + if len(channelIds) > 0 { + channels, err = a.Srv().Store.Channel().GetChannelsByIds(channelIds, false) + if err != nil { + return nil, nil, nil, model.NewAppError("prepareInviteNewUsersToTeam", "app.channel.get_channels_by_ids.app_error", nil, err.Error(), http.StatusInternalServerError) + } + } result := <-tchan if result.NErr != nil { var nfErr *store.ErrNotFound switch { case errors.As(result.NErr, &nfErr): - return nil, nil, model.NewAppError("prepareInviteNewUsersToTeam", "app.team.get_by_invite_id.finding.app_error", nil, nfErr.Error(), http.StatusNotFound) + return nil, nil, nil, model.NewAppError("prepareInviteNewUsersToTeam", "app.team.get_by_invite_id.finding.app_error", nil, nfErr.Error(), http.StatusNotFound) default: - return nil, nil, model.NewAppError("prepareInviteNewUsersToTeam", "app.team.get_by_invite_id.finding.app_error", nil, result.NErr.Error(), http.StatusInternalServerError) + return nil, nil, nil, model.NewAppError("prepareInviteNewUsersToTeam", "app.team.get_by_invite_id.finding.app_error", nil, result.NErr.Error(), http.StatusInternalServerError) } } team := result.Data.(*model.Team) @@ -1239,25 +1247,34 @@ func (a *App) prepareInviteNewUsersToTeam(teamID, senderId string) (*model.User, var nfErr *store.ErrNotFound switch { case errors.As(result.NErr, &nfErr): - return nil, nil, model.NewAppError("prepareInviteNewUsersToTeam", MissingAccountError, nil, nfErr.Error(), http.StatusNotFound) + return nil, nil, nil, model.NewAppError("prepareInviteNewUsersToTeam", MissingAccountError, nil, nfErr.Error(), http.StatusNotFound) default: - return nil, nil, model.NewAppError("prepareInviteNewUsersToTeam", "app.user.get.app_error", nil, result.NErr.Error(), http.StatusInternalServerError) + return nil, nil, nil, model.NewAppError("prepareInviteNewUsersToTeam", "app.user.get.app_error", nil, result.NErr.Error(), http.StatusInternalServerError) } } user := result.Data.(*model.User) - return user, team, nil + + for _, channel := range channels { + if channel.TeamId != teamID { + return nil, nil, nil, model.NewAppError("prepareInviteGuestsToChannels", "api.team.invite_guests.channel_in_invalid_team.app_error", nil, "", http.StatusBadRequest) + } + } + + return user, team, channels, nil } -func (a *App) InviteNewUsersToTeamGracefully(emailList []string, teamID, senderId string, reminderInterval string) ([]*model.EmailInviteWithError, *model.AppError) { +func (a *App) InviteNewUsersToTeamGracefully(memberInvite *model.MemberInvite, teamID, senderId string, reminderInterval string) ([]*model.EmailInviteWithError, *model.AppError) { if !*a.Config().ServiceSettings.EnableEmailInvitations { return nil, model.NewAppError("InviteNewUsersToTeam", "api.team.invite_members.disabled.app_error", nil, "", http.StatusNotImplemented) } + emailList := memberInvite.Emails + if len(emailList) == 0 { err := model.NewAppError("InviteNewUsersToTeam", "api.team.invite_members.no_one.app_error", nil, "", http.StatusBadRequest) return nil, err } - user, team, err := a.prepareInviteNewUsersToTeam(teamID, senderId) + user, team, channels, err := a.prepareInviteNewUsersToTeam(teamID, senderId, memberInvite.ChannelIds) if err != nil { return nil, err } @@ -1284,25 +1301,36 @@ func (a *App) InviteNewUsersToTeamGracefully(emailList []string, teamID, senderI if len(goodEmails) > 0 { nameFormat := *a.Config().TeamSettings.TeammateNameDisplay - eErr := a.Srv().EmailService.SendInviteEmails(team, user.GetDisplayName(nameFormat), user.Id, goodEmails, a.GetSiteURL(), reminderData, true) + senderProfileImage, _, err := a.GetProfileImage(user) + if err != nil { + a.Log().Warn("Unable to get the sender user profile image.", mlog.String("user_id", user.Id), mlog.String("team_id", team.Id), mlog.Err(err)) + } + var eErr error + var invitesWithErrors2 []*model.EmailInviteWithError + if len(channels) > 0 { + invitesWithErrors2, eErr = a.Srv().EmailService.SendInviteEmailsToTeamAndChannels(team, channels, user.GetDisplayName(nameFormat), user.Id, senderProfileImage, goodEmails, a.GetSiteURL(), reminderData, memberInvite.Message, true) + inviteListWithErrors = append(inviteListWithErrors, invitesWithErrors2...) + } else { + eErr = a.Srv().EmailService.SendInviteEmails(team, user.GetDisplayName(nameFormat), user.Id, goodEmails, a.GetSiteURL(), reminderData, true) + } if eErr != nil { switch { case errors.Is(eErr, email.SendMailError): for i := range inviteListWithErrors { if inviteListWithErrors[i].Error == nil { if *a.Config().EmailSettings.SMTPServer == model.EmailSMTPDefaultServer && *a.Config().EmailSettings.SMTPPort == model.EmailSMTPDefaultPort { - inviteListWithErrors[i].Error = model.NewAppError("InviteGuestsToChannelsGracefully", "api.team.invite_members.unable_to_send_email_with_defaults.app_error", nil, "", http.StatusInternalServerError) + inviteListWithErrors[i].Error = model.NewAppError("InviteNewUsersToTeamGracefully", "api.team.invite_members.unable_to_send_email_with_defaults.app_error", nil, "", http.StatusInternalServerError) } else { - inviteListWithErrors[i].Error = model.NewAppError("SendInviteEmails", "api.team.invite_members.unable_to_send_email.app_error", nil, "", http.StatusInternalServerError) + inviteListWithErrors[i].Error = model.NewAppError("InviteNewUsersToTeamGracefully", "api.team.invite_members.unable_to_send_email.app_error", nil, "", http.StatusInternalServerError) } } } case errors.Is(eErr, email.NoRateLimiterError): - return nil, model.NewAppError("SendInviteEmails", "app.email.no_rate_limiter.app_error", nil, fmt.Sprintf("user_id=%s, team_id=%s", user.Id, team.Id), http.StatusInternalServerError) + return nil, model.NewAppError("InviteNewUsersToTeamGracefully", "app.email.no_rate_limiter.app_error", nil, fmt.Sprintf("user_id=%s, team_id=%s", user.Id, team.Id), http.StatusInternalServerError) case errors.Is(eErr, email.SetupRateLimiterError): - return nil, model.NewAppError("SendInviteEmails", "app.email.setup_rate_limiter.app_error", nil, fmt.Sprintf("user_id=%s, team_id=%s, error=%v", user.Id, team.Id, eErr), http.StatusInternalServerError) + return nil, model.NewAppError("InviteNewUsersToTeamGracefully", "app.email.setup_rate_limiter.app_error", nil, fmt.Sprintf("user_id=%s, team_id=%s, error=%v", user.Id, team.Id, eErr), http.StatusInternalServerError) default: - return nil, model.NewAppError("SendInviteEmails", "app.email.rate_limit_exceeded.app_error", nil, fmt.Sprintf("user_id=%s, team_id=%s, error=%v", user.Id, team.Id, eErr), http.StatusRequestEntityTooLarge) + return nil, model.NewAppError("InviteNewUsersToTeamGracefully", "app.email.rate_limit_exceeded.app_error", nil, fmt.Sprintf("user_id=%s, team_id=%s, error=%v", user.Id, team.Id, eErr), http.StatusRequestEntityTooLarge) } } } @@ -1440,7 +1468,7 @@ func (a *App) InviteNewUsersToTeam(emailList []string, teamID, senderId string) return err } - user, team, err := a.prepareInviteNewUsersToTeam(teamID, senderId) + user, team, _, err := a.prepareInviteNewUsersToTeam(teamID, senderId, []string{}) if err != nil { return err } diff --git a/app/team_test.go b/app/team_test.go index 964cb10c4e..91afedbef0 100644 --- a/app/team_test.go +++ b/app/team_test.go @@ -1255,6 +1255,79 @@ func TestInviteNewUsersToTeamGracefully(t *testing.T) { t.Run("it return list of email with no error on success", func(t *testing.T) { emailServiceMock := emailmocks.ServiceInterface{} + memberInvite := &model.MemberInvite{ + Emails: []string{"idontexist@mattermost.com"}, + } + emailServiceMock.On("SendInviteEmails", + mock.AnythingOfType("*model.Team"), + mock.AnythingOfType("string"), + mock.AnythingOfType("string"), + memberInvite.Emails, + "", + mock.Anything, + true, + ).Once().Return(nil) + th.App.Srv().EmailService = &emailServiceMock + + res, err := th.App.InviteNewUsersToTeamGracefully(memberInvite, th.BasicTeam.Id, th.BasicUser.Id, "") + require.Nil(t, err) + require.Len(t, res, 1) + require.Nil(t, res[0].Error) + }) + + t.Run("it should assign errors to emails when failing to send", func(t *testing.T) { + emailServiceMock := emailmocks.ServiceInterface{} + memberInvite := &model.MemberInvite{ + Emails: []string{"idontexist@mattermost.com"}, + } + emailServiceMock.On("SendInviteEmails", + mock.AnythingOfType("*model.Team"), + mock.AnythingOfType("string"), + mock.AnythingOfType("string"), + memberInvite.Emails, + "", + mock.Anything, + true, + ).Once().Return(email.SendMailError) + th.App.Srv().EmailService = &emailServiceMock + + res, err := th.App.InviteNewUsersToTeamGracefully(memberInvite, th.BasicTeam.Id, th.BasicUser.Id, "") + require.Nil(t, err) + require.Len(t, res, 1) + require.NotNil(t, res[0].Error) + }) + + t.Run("it return list of email with no error when inviting to team and channels using memberInvite struct", func(t *testing.T) { + emailServiceMock := emailmocks.ServiceInterface{} + memberInvite := &model.MemberInvite{ + Emails: []string{"idontexist@mattermost.com"}, + ChannelIds: []string{th.BasicChannel.Id}, + } + emailServiceMock.On("SendInviteEmailsToTeamAndChannels", + mock.AnythingOfType("*model.Team"), + mock.AnythingOfType("[]*model.Channel"), + mock.AnythingOfType("string"), + mock.AnythingOfType("string"), + mock.AnythingOfType("[]uint8"), + memberInvite.Emails, + "", + mock.Anything, + mock.AnythingOfType("string"), + true, + ).Once().Return([]*model.EmailInviteWithError{}, nil) + th.App.Srv().EmailService = &emailServiceMock + + res, err := th.App.InviteNewUsersToTeamGracefully(memberInvite, th.BasicTeam.Id, th.BasicUser.Id, "") + require.Nil(t, err) + require.Len(t, res, 1) + require.Nil(t, res[0].Error) + }) + + t.Run("it return list of email with no error when inviting to team and channels using plain emails array", func(t *testing.T) { + emailServiceMock := emailmocks.ServiceInterface{} + memberInvite := &model.MemberInvite{ + Emails: []string{"idontexist@mattermost.com"}, + } emailServiceMock.On("SendInviteEmails", mock.AnythingOfType("*model.Team"), mock.AnythingOfType("string"), @@ -1266,30 +1339,11 @@ func TestInviteNewUsersToTeamGracefully(t *testing.T) { ).Once().Return(nil) th.App.Srv().EmailService = &emailServiceMock - res, err := th.App.InviteNewUsersToTeamGracefully([]string{"idontexist@mattermost.com"}, th.BasicTeam.Id, th.BasicUser.Id, "") + res, err := th.App.InviteNewUsersToTeamGracefully(memberInvite, th.BasicTeam.Id, th.BasicUser.Id, "") require.Nil(t, err) require.Len(t, res, 1) require.Nil(t, res[0].Error) }) - - t.Run("it should assign errors to emails when failing to send", func(t *testing.T) { - emailServiceMock := emailmocks.ServiceInterface{} - emailServiceMock.On("SendInviteEmails", - mock.AnythingOfType("*model.Team"), - mock.AnythingOfType("string"), - mock.AnythingOfType("string"), - []string{"idontexist@mattermost.com"}, - "", - mock.Anything, - true, - ).Once().Return(email.SendMailError) - th.App.Srv().EmailService = &emailServiceMock - - res, err := th.App.InviteNewUsersToTeamGracefully([]string{"idontexist@mattermost.com"}, th.BasicTeam.Id, th.BasicUser.Id, "") - require.Nil(t, err) - require.Len(t, res, 1) - require.NotNil(t, res[0].Error) - }) } func TestInviteGuestsToChannelsGracefully(t *testing.T) { diff --git a/app/user.go b/app/user.go index 48bccc769d..fb2883bc61 100644 --- a/app/user.go +++ b/app/user.go @@ -99,7 +99,7 @@ func (a *App) CreateUserWithToken(c *request.Context, user *model.User, token *m a.AddDirectChannels(team.Id, ruser) - if token.Type == TokenTypeGuestInvitation { + if token.Type == TokenTypeGuestInvitation || (token.Type == TokenTypeTeamInvitation && len(channels) > 0) { for _, channel := range channels { _, err := a.AddChannelMember(c, ruser.Id, channel, ChannelMemberOpts{}) if err != nil { diff --git a/i18n/en.json b/i18n/en.json index 6abd1ce53f..9a49abeaf2 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -2971,6 +2971,14 @@ "id": "api.team.invite_members.unable_to_send_email_with_defaults.app_error", "translation": "SMTP is not configured in System Console" }, + { + "id": "api.team.invite_members_to_team_and_channels.invalid_body.app_error", + "translation": "Invalid request body." + }, + { + "id": "api.team.invite_members_to_team_and_channels.invalid_body_parsing.app_error", + "translation": "Error while parsing the body data." + }, { "id": "api.team.is_team_creation_allowed.disabled.app_error", "translation": "Team creation has been disabled. Please ask your System Administrator for details." @@ -3311,6 +3319,22 @@ "id": "api.templates.invite_subject", "translation": "[{{ .SiteName }}] {{ .SenderName }} invited you to join {{ .TeamDisplayName }} Team" }, + { + "id": "api.templates.invite_team_and_channel_body.title", + "translation": "{{ .SenderName }} invited you to join {{ .ChannelName }} on the {{ .TeamDisplayName }} Team" + }, + { + "id": "api.templates.invite_team_and_channel_subject", + "translation": "[{{ .SiteName }}] {{ .SenderName }} invited you to join {{ .ChannelName }} on the {{ .TeamDisplayName }} Team" + }, + { + "id": "api.templates.invite_team_and_channels_body.title", + "translation": "{{ .SenderName }} invited you to join {{ .ChannelsLen }} channels on the {{ .TeamDisplayName }} Team" + }, + { + "id": "api.templates.invite_team_and_channels_subject", + "translation": "[{{ .SiteName }}] {{ .SenderName }} invited you to join {{ .ChannelsLen }} channels on the {{ .TeamDisplayName }} Team" + }, { "id": "api.templates.license_up_for_renewal_renew_now", "translation": "Renew now" @@ -8471,6 +8495,14 @@ "id": "model.link_metadata.is_valid.url.app_error", "translation": "Link metadata URL must be set." }, + { + "id": "model.member.is_valid.channel.app_error", + "translation": "Channel name is not valid" + }, + { + "id": "model.member.is_valid.emails.app_error", + "translation": "Email list is empty" + }, { "id": "model.oauth.is_valid.app_id.app_error", "translation": "Invalid app id." diff --git a/jobs/resend_invitation_email/worker.go b/jobs/resend_invitation_email/worker.go index 2741879018..d193b55261 100644 --- a/jobs/resend_invitation_email/worker.go +++ b/jobs/resend_invitation_email/worker.go @@ -22,7 +22,7 @@ type AppIface interface { configservice.ConfigService GetUserByEmail(email string) (*model.User, *model.AppError) GetTeamMembersByIds(teamID string, userIDs []string, restrictions *model.ViewUsersRestrictions) ([]*model.TeamMember, *model.AppError) - InviteNewUsersToTeamGracefully(emailList []string, teamID, senderId string, reminderInterval string) ([]*model.EmailInviteWithError, *model.AppError) + InviteNewUsersToTeamGracefully(memberInvite *model.MemberInvite, teamID, senderId string, reminderInterval string) ([]*model.EmailInviteWithError, *model.AppError) } type ResendInvitationEmailWorker struct { @@ -116,6 +116,17 @@ func (rseworker *ResendInvitationEmailWorker) cleanEmailData(emailStringData str return emails, nil } +func (rseworker *ResendInvitationEmailWorker) cleanChannelsData(channelStringData string) ([]string, error) { + // channelStringData looks like this ["uuuiiiiidddd","uuuiiiiidddd"] + channels := []string{} + err := json.Unmarshal([]byte(channelStringData), &channels) + if err != nil { + return nil, err + } + + return channels, nil +} + func (rseworker *ResendInvitationEmailWorker) removeAlreadyJoined(teamID string, emailList []string) []string { var notJoinedYet []string for _, email := range emailList { @@ -161,6 +172,7 @@ func (rseworker *ResendInvitationEmailWorker) TearDown(job *model.Job) { func (rseworker *ResendInvitationEmailWorker) ResendEmails(job *model.Job, interval string) { teamID := job.Data["teamID"] emailListData := job.Data["emailList"] + channelListData := job.Data["channelList"] emailList, err := rseworker.cleanEmailData(emailListData) if err != nil { @@ -169,9 +181,24 @@ func (rseworker *ResendInvitationEmailWorker) ResendEmails(job *model.Job, inter rseworker.setJobError(job, appErr) } + channelList, err := rseworker.cleanChannelsData(channelListData) + if err != nil { + appErr := model.NewAppError("worker: "+rseworker.name, "job_id: "+job.Id, nil, err.Error(), http.StatusInternalServerError) + mlog.Error("Worker: Failed to clean channel string data", mlog.String("worker", rseworker.name), mlog.String("job_id", job.Id), mlog.String("error", appErr.Error())) + rseworker.setJobError(job, appErr) + } + emailList = rseworker.removeAlreadyJoined(teamID, emailList) - _, appErr := rseworker.app.InviteNewUsersToTeamGracefully(emailList, teamID, job.Data["senderID"], interval) + memberInvite := model.MemberInvite{ + Emails: emailList, + } + + if len(channelList) > 0 { + memberInvite.ChannelIds = channelList + } + + _, appErr := rseworker.app.InviteNewUsersToTeamGracefully(&memberInvite, teamID, job.Data["senderID"], interval) if appErr != nil { mlog.Error("Worker: Failed to send emails", mlog.String("worker", rseworker.name), mlog.String("job_id", job.Id), mlog.String("error", appErr.Error())) rseworker.setJobError(job, appErr) diff --git a/model/client4.go b/model/client4.go index ba51b5ce01..bb0864e2a7 100644 --- a/model/client4.go +++ b/model/client4.go @@ -2638,6 +2638,30 @@ func (c *Client4) InviteGuestsToTeam(teamId string, userEmails []string, channel // InviteUsersToTeam invite users by email to the team. func (c *Client4) InviteUsersToTeamGracefully(teamId string, userEmails []string) ([]*EmailInviteWithError, *Response, error) { r, err := c.DoAPIPost(c.teamRoute(teamId)+"/invite/email?graceful="+c.boolString(true), ArrayToJSON(userEmails)) + + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + var list []*EmailInviteWithError + if jsonErr := json.NewDecoder(r.Body).Decode(&list); jsonErr != nil { + return nil, nil, NewAppError("InviteUsersToTeamGracefully", "api.unmarshal_error", nil, jsonErr.Error(), http.StatusInternalServerError) + } + return list, BuildResponse(r), nil +} + +// InviteUsersToTeam invite users by email to the team. +func (c *Client4) InviteUsersToTeamAndChannelsGracefully(teamId string, userEmails []string, channelIds []string, message string) ([]*EmailInviteWithError, *Response, error) { + memberInvite := MemberInvite{ + Emails: userEmails, + ChannelIds: channelIds, + Message: message, + } + buf, err := json.Marshal(memberInvite) + if err != nil { + return nil, nil, NewAppError("InviteMembersToTeamAndChannels", "api.marshal_error", nil, err.Error(), http.StatusInternalServerError) + } + r, err := c.DoAPIPostBytes(c.teamRoute(teamId)+"/invite/email?graceful="+c.boolString(true), buf) if err != nil { return nil, BuildResponse(r), err } diff --git a/model/member_invite.go b/model/member_invite.go new file mode 100644 index 0000000000..94258dbeb3 --- /dev/null +++ b/model/member_invite.go @@ -0,0 +1,49 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +import ( + "encoding/json" + "net/http" +) + +type MemberInvite struct { + Emails []string `json:"emails"` + ChannelIds []string `json:"channelIds,omitempty"` + Message string `json:"message"` +} + +// IsValid validates that the invitation info is loaded correctly and with the correct structure +func (i *MemberInvite) IsValid() *AppError { + if len(i.Emails) == 0 { + return NewAppError("MemberInvite.IsValid", "model.member.is_valid.emails.app_error", nil, "", http.StatusBadRequest) + } + + if len(i.ChannelIds) > 0 { + for _, channel := range i.ChannelIds { + if len(channel) != 26 { + return NewAppError("MemberInvite.IsValid", "model.member.is_valid.channel.app_error", nil, "channel="+channel, http.StatusBadRequest) + } + } + } + + return nil +} + +func (i *MemberInvite) UnmarshalJSON(b []byte) error { + var emails []string + if err := json.Unmarshal(b, &emails); err == nil { + *i = MemberInvite{} + i.Emails = emails + return nil + } + + type TempMemberInvite MemberInvite + var o2 TempMemberInvite + if err := json.Unmarshal(b, &o2); err != nil { + return err + } + *i = MemberInvite(o2) + return nil +}