diff --git a/api4/cloud.go b/api4/cloud.go index e5405c16d0..b23bda10bd 100644 --- a/api4/cloud.go +++ b/api4/cloud.go @@ -435,8 +435,8 @@ func handleCWSWebhook(c *Context, w http.ResponseWriter, r *http.Request) { return } - if appErr := c.App.Srv().EmailService.SendCloudWelcomeEmail(user.Email, user.Locale, team.InviteId, subscription.GetWorkSpaceNameFromDNS(), subscription.DNS, *c.App.Config().ServiceSettings.SiteURL); appErr != nil { - c.Err = appErr + if err := c.App.Srv().EmailService.SendCloudWelcomeEmail(user.Email, user.Locale, team.InviteId, subscription.GetWorkSpaceNameFromDNS(), subscription.DNS, *c.App.Config().ServiceSettings.SiteURL); err != nil { + c.Err = model.NewAppError("SendCloudWelcomeEmail", "api.user.send_cloud_welcome_email.error", nil, err.Error(), http.StatusInternalServerError) return } case model.EventTypeTrialWillEnd: diff --git a/api4/team_local.go b/api4/team_local.go index fe5d7039b0..18a070542f 100644 --- a/api4/team_local.go +++ b/api4/team_local.go @@ -4,11 +4,13 @@ package api4 import ( + "fmt" "net/http" "strings" "github.com/pkg/errors" + "github.com/mattermost/mattermost-server/v5/app/email" "github.com/mattermost/mattermost-server/v5/audit" "github.com/mattermost/mattermost-server/v5/model" "github.com/mattermost/mattermost-server/v5/store" @@ -126,7 +128,14 @@ func localInviteUsersToTeam(c *Context, w http.ResponseWriter, r *http.Request) if len(goodEmails) > 0 { err := c.App.Srv().EmailService.SendInviteEmails(team, "Administrator", "mmctl "+model.NewId(), goodEmails, *c.App.Config().ServiceSettings.SiteURL) if err != nil { - c.Err = err + 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) + case errors.Is(err, email.SetupRateLimiterError): + c.Err = model.NewAppError("SendInviteEmails", "app.email.setup_rate_limiter.app_error", nil, fmt.Sprintf("team_id=%s, error=%v", team.Id, err), http.StatusInternalServerError) + default: + c.Err = model.NewAppError("SendInviteEmails", "app.email.rate_limit_exceeded.app_error", nil, fmt.Sprintf("team_id=%s, error=%v", team.Id, err), http.StatusRequestEntityTooLarge) + } return } } @@ -147,7 +156,14 @@ func localInviteUsersToTeam(c *Context, w http.ResponseWriter, r *http.Request) } err := c.App.Srv().EmailService.SendInviteEmails(team, "Administrator", "mmctl "+model.NewId(), emailList, *c.App.Config().ServiceSettings.SiteURL) if err != nil { - c.Err = err + 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) + case errors.Is(err, email.SetupRateLimiterError): + c.Err = model.NewAppError("SendInviteEmails", "app.email.setup_rate_limiter.app_error", nil, fmt.Sprintf("team_id=%s, error=%v", team.Id, err), http.StatusInternalServerError) + default: + c.Err = model.NewAppError("SendInviteEmails", "app.email.rate_limit_exceeded.app_error", nil, fmt.Sprintf("team_id=%s, error=%v", team.Id, err), http.StatusRequestEntityTooLarge) + } return } ReturnStatusOK(w) diff --git a/api4/user.go b/api4/user.go index 6e78f562a8..5e8d677039 100644 --- a/api4/user.go +++ b/api4/user.go @@ -1398,8 +1398,8 @@ func updateUserActive(c *Context, w http.ResponseWriter, r *http.Request) { if isSelfDeactive { c.App.Srv().Go(func() { - if err = c.App.Srv().EmailService.SendDeactivateAccountEmail(user.Email, user.Locale, c.App.GetSiteURL()); err != nil { - c.LogErrorByCode(err) + if err := c.App.Srv().EmailService.SendDeactivateAccountEmail(user.Email, user.Locale, c.App.GetSiteURL()); err != nil { + c.LogErrorByCode(model.NewAppError("SendDeactivateEmail", "api.user.send_deactivate_email_and_forget.failed.error", nil, err.Error(), http.StatusInternalServerError)) } }) } diff --git a/api4/user_test.go b/api4/user_test.go index c821774553..fae1ce4746 100644 --- a/api4/user_test.go +++ b/api4/user_test.go @@ -3245,7 +3245,7 @@ func TestVerifyUserEmail(t *testing.T) { ruser, _ := th.Client.CreateUser(&user) token, err := th.App.Srv().EmailService.CreateVerifyEmailToken(ruser.Id, email) - require.Nil(t, err, "Unable to create email verify token") + require.NoError(t, err, "Unable to create email verify token") _, resp := th.Client.VerifyUserEmail(token.Token) CheckNoError(t, resp) diff --git a/app/app.go b/app/app.go index 9bcedcae3e..6a2c9455b6 100644 --- a/app/app.go +++ b/app/app.go @@ -369,7 +369,7 @@ func (a *App) NotifyAndSetWarnMetricAck(warnMetricId string, sender *model.User, return model.NewAppError("NotifyAndSetWarnMetricAck", "api.email.send_warn_metric_ack.missing_server.app_error", nil, i18n.T("api.context.invalid_param.app_error", map[string]interface{}{"Name": "SMTPServer"}), http.StatusInternalServerError) } T := i18n.GetUserTranslations(sender.Locale) - data := a.Srv().EmailService.newEmailTemplateData(sender.Locale) + data := a.Srv().EmailService.NewEmailTemplateData(sender.Locale) data.Props["ContactNameHeader"] = T("api.templates.warn_metric_ack.body.contact_name_header") data.Props["ContactNameValue"] = sender.GetFullName() data.Props["ContactEmailHeader"] = T("api.templates.warn_metric_ack.body.contact_email_header") diff --git a/app/channel_test.go b/app/channel_test.go index 69796ec606..62a0188221 100644 --- a/app/channel_test.go +++ b/app/channel_test.go @@ -1977,6 +1977,7 @@ func TestMarkChannelsAsViewedPanic(t *testing.T) { SessionStore: &mockSessionStore, OAuthStore: &mockOAuthStore, ConfigFn: th.App.srv.Config, + LicenseFn: th.App.srv.License, }) require.NoError(t, err) mockPreferenceStore := mocks.PreferenceStore{} diff --git a/app/cloud.go b/app/cloud.go index 37bcf67f7e..db433cd0c2 100644 --- a/app/cloud.go +++ b/app/cloud.go @@ -179,8 +179,8 @@ func (a *App) CheckAndSendUserLimitWarningEmails(c *request.Context) *model.AppE } else if remainingUsers == 0 { // At limit for admin := range sysAdmins { - _, appErr := a.Srv().EmailService.SendAtUserLimitWarningEmail(sysAdmins[admin].Email, sysAdmins[admin].Locale, *a.Config().ServiceSettings.SiteURL) - if appErr != nil { + _, err := a.Srv().EmailService.SendAtUserLimitWarningEmail(sysAdmins[admin].Email, sysAdmins[admin].Locale, *a.Config().ServiceSettings.SiteURL) + if err != nil { a.Log().Error( "Error sending user limit warning email to admin", mlog.String("username", sysAdmins[admin].Username), diff --git a/app/email.go b/app/email/email.go similarity index 53% rename from app/email.go rename to app/email/email.go index 06e54558ed..f4db97b9c1 100644 --- a/app/email.go +++ b/app/email/email.go @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -package app +package email import ( "bytes" @@ -11,165 +11,99 @@ import ( "io" "net/http" "net/url" - "path" "strings" "time" - "github.com/pkg/errors" - "github.com/throttled/throttled" - "github.com/throttled/throttled/store/memstore" - "github.com/mattermost/mattermost-server/v5/model" "github.com/mattermost/mattermost-server/v5/shared/i18n" "github.com/mattermost/mattermost-server/v5/shared/mail" "github.com/mattermost/mattermost-server/v5/shared/mlog" "github.com/mattermost/mattermost-server/v5/shared/templates" + "github.com/pkg/errors" ) -const ( - emailRateLimitingMemstoreSize = 65536 - emailRateLimitingPerHour = 20 - emailRateLimitingMaxBurst = 20 -) - -func condenseSiteURL(siteURL string) string { - parsedSiteURL, _ := url.Parse(siteURL) - if parsedSiteURL.Path == "" || parsedSiteURL.Path == "/" { - return parsedSiteURL.Host - } - - return path.Join(parsedSiteURL.Host, parsedSiteURL.Path) -} - -type EmailService struct { - srv *Server - PerHourEmailRateLimiter *throttled.GCRARateLimiter - PerDayEmailRateLimiter *throttled.GCRARateLimiter - EmailBatching *EmailBatchingJob -} - -func NewEmailService(srv *Server) (*EmailService, error) { - service := &EmailService{srv: srv} - if err := service.setUpRateLimiters(); err != nil { - return nil, err - } - service.InitEmailBatching() - return service, nil -} - -func (es *EmailService) setUpRateLimiters() error { - store, err := memstore.New(emailRateLimitingMemstoreSize) - if err != nil { - return errors.Wrap(err, "Unable to setup email rate limiting memstore.") - } - - perHourQuota := throttled.RateQuota{ - MaxRate: throttled.PerHour(emailRateLimitingPerHour), - MaxBurst: emailRateLimitingMaxBurst, - } - - perDayQuota := throttled.RateQuota{ - MaxRate: throttled.PerDay(1), - MaxBurst: 0, - } - - perHourRateLimiter, err := throttled.NewGCRARateLimiter(store, perHourQuota) - if err != nil || perHourRateLimiter == nil { - return errors.Wrap(err, "Unable to setup email rate limiting GCRA rate limiter.") - } - - perDayRateLimiter, err := throttled.NewGCRARateLimiter(store, perDayQuota) - if err != nil || perDayRateLimiter == nil { - return errors.Wrap(err, "Unable to setup per day email rate limiting GCRA rate limiter.") - } - - es.PerHourEmailRateLimiter = perHourRateLimiter - es.PerDayEmailRateLimiter = perDayRateLimiter - return nil -} - -func (es *EmailService) sendChangeUsernameEmail(newUsername, email, locale, siteURL string) *model.AppError { +func (es *Service) SendChangeUsernameEmail(newUsername, email, locale, siteURL string) error { T := i18n.GetUserTranslations(locale) subject := T("api.templates.username_change_subject", - map[string]interface{}{"SiteName": es.srv.Config().TeamSettings.SiteName, - "TeamDisplayName": es.srv.Config().TeamSettings.SiteName}) + map[string]interface{}{"SiteName": es.config().TeamSettings.SiteName, + "TeamDisplayName": es.config().TeamSettings.SiteName}) - data := es.newEmailTemplateData(locale) + data := es.NewEmailTemplateData(locale) data.Props["SiteURL"] = siteURL data.Props["Title"] = T("api.templates.username_change_body.title") data.Props["Info"] = T("api.templates.username_change_body.info", - map[string]interface{}{"TeamDisplayName": es.srv.Config().TeamSettings.SiteName, "NewUsername": newUsername}) + map[string]interface{}{"TeamDisplayName": es.config().TeamSettings.SiteName, "NewUsername": newUsername}) data.Props["Warning"] = T("api.templates.email_warning") - body, err := es.srv.TemplatesContainer().RenderToString("email_change_body", data) + body, err := es.templatesContainer.RenderToString("email_change_body", data) if err != nil { - return model.NewAppError("sendChangeUsernameEmail", "api.user.send_email_change_username_and_forget.error", nil, err.Error(), http.StatusInternalServerError) + return err } if err := es.sendMail(email, subject, body); err != nil { - return model.NewAppError("sendChangeUsernameEmail", "api.user.send_email_change_username_and_forget.error", nil, err.Error(), http.StatusInternalServerError) + return err } return nil } -func (es *EmailService) sendEmailChangeVerifyEmail(newUserEmail, locale, siteURL, token string) *model.AppError { +func (es *Service) SendEmailChangeVerifyEmail(newUserEmail, locale, siteURL, token string) error { T := i18n.GetUserTranslations(locale) link := fmt.Sprintf("%s/do_verify_email?token=%s&email=%s", siteURL, token, url.QueryEscape(newUserEmail)) subject := T("api.templates.email_change_verify_subject", - map[string]interface{}{"SiteName": es.srv.Config().TeamSettings.SiteName, - "TeamDisplayName": es.srv.Config().TeamSettings.SiteName}) + map[string]interface{}{"SiteName": es.config().TeamSettings.SiteName, + "TeamDisplayName": es.config().TeamSettings.SiteName}) - data := es.newEmailTemplateData(locale) + data := es.NewEmailTemplateData(locale) data.Props["SiteURL"] = siteURL data.Props["Title"] = T("api.templates.email_change_verify_body.title") data.Props["Info"] = T("api.templates.email_change_verify_body.info", - map[string]interface{}{"TeamDisplayName": es.srv.Config().TeamSettings.SiteName}) + map[string]interface{}{"TeamDisplayName": es.config().TeamSettings.SiteName}) data.Props["VerifyUrl"] = link data.Props["VerifyButton"] = T("api.templates.email_change_verify_body.button") - body, err := es.srv.TemplatesContainer().RenderToString("email_change_verify_body", data) + body, err := es.templatesContainer.RenderToString("email_change_verify_body", data) if err != nil { - return model.NewAppError("sendEmailChangeVerifyEmail", "api.user.send_email_change_verify_email_and_forget.error", nil, err.Error(), http.StatusInternalServerError) + return err } if err := es.sendMail(newUserEmail, subject, body); err != nil { - return model.NewAppError("sendEmailChangeVerifyEmail", "api.user.send_email_change_verify_email_and_forget.error", nil, err.Error(), http.StatusInternalServerError) + return err } return nil } -func (es *EmailService) sendEmailChangeEmail(oldEmail, newEmail, locale, siteURL string) *model.AppError { +func (es *Service) SendEmailChangeEmail(oldEmail, newEmail, locale, siteURL string) error { T := i18n.GetUserTranslations(locale) subject := T("api.templates.email_change_subject", - map[string]interface{}{"SiteName": es.srv.Config().TeamSettings.SiteName, - "TeamDisplayName": es.srv.Config().TeamSettings.SiteName}) + map[string]interface{}{"SiteName": es.config().TeamSettings.SiteName, + "TeamDisplayName": es.config().TeamSettings.SiteName}) - data := es.newEmailTemplateData(locale) + data := es.NewEmailTemplateData(locale) data.Props["SiteURL"] = siteURL data.Props["Title"] = T("api.templates.email_change_body.title") data.Props["Info"] = T("api.templates.email_change_body.info", - map[string]interface{}{"TeamDisplayName": es.srv.Config().TeamSettings.SiteName, "NewEmail": newEmail}) + map[string]interface{}{"TeamDisplayName": es.config().TeamSettings.SiteName, "NewEmail": newEmail}) data.Props["Warning"] = T("api.templates.email_warning") - body, err := es.srv.TemplatesContainer().RenderToString("email_change_body", data) + body, err := es.templatesContainer.RenderToString("email_change_body", data) if err != nil { - return model.NewAppError("sendEmailChangeEmail", "api.user.send_email_change_email_and_forget.error", nil, err.Error(), http.StatusInternalServerError) + return err } if err := es.sendMail(oldEmail, subject, body); err != nil { - return model.NewAppError("sendEmailChangeEmail", "api.user.send_email_change_email_and_forget.error", nil, err.Error(), http.StatusInternalServerError) + return err } return nil } -func (es *EmailService) sendVerifyEmail(userEmail, locale, siteURL, token, redirect string) *model.AppError { +func (es *Service) SendVerifyEmail(userEmail, locale, siteURL, token, redirect string) error { T := i18n.GetUserTranslations(locale) link := fmt.Sprintf("%s/do_verify_email?token=%s&email=%s", siteURL, token, url.QueryEscape(userEmail)) @@ -180,9 +114,9 @@ func (es *EmailService) sendVerifyEmail(userEmail, locale, siteURL, token, redir serverURL := condenseSiteURL(siteURL) subject := T("api.templates.verify_subject", - map[string]interface{}{"SiteName": es.srv.Config().TeamSettings.SiteName}) + map[string]interface{}{"SiteName": es.config().TeamSettings.SiteName}) - data := es.newEmailTemplateData(locale) + data := es.NewEmailTemplateData(locale) data.Props["SiteURL"] = siteURL data.Props["Title"] = T("api.templates.verify_body.title") data.Props["SubTitle1"] = T("api.templates.verify_body.subTitle1") @@ -195,49 +129,49 @@ func (es *EmailService) sendVerifyEmail(userEmail, locale, siteURL, token, redir data.Props["QuestionTitle"] = T("api.templates.questions_footer.title") data.Props["QuestionInfo"] = T("api.templates.questions_footer.info") - body, err := es.srv.TemplatesContainer().RenderToString("verify_body", data) + body, err := es.templatesContainer.RenderToString("verify_body", data) if err != nil { - return model.NewAppError("SendVerifyEmail", "api.user.send_verify_email_and_forget.failed.error", nil, err.Error(), http.StatusInternalServerError) + return err } if err := es.sendMail(userEmail, subject, body); err != nil { - return model.NewAppError("SendVerifyEmail", "api.user.send_verify_email_and_forget.failed.error", nil, err.Error(), http.StatusInternalServerError) + return err } return nil } -func (es *EmailService) SendSignInChangeEmail(email, method, locale, siteURL string) *model.AppError { +func (es *Service) SendSignInChangeEmail(email, method, locale, siteURL string) error { T := i18n.GetUserTranslations(locale) subject := T("api.templates.signin_change_email.subject", - map[string]interface{}{"SiteName": es.srv.Config().TeamSettings.SiteName}) + map[string]interface{}{"SiteName": es.config().TeamSettings.SiteName}) - data := es.newEmailTemplateData(locale) + data := es.NewEmailTemplateData(locale) data.Props["SiteURL"] = siteURL data.Props["Title"] = T("api.templates.signin_change_email.body.title") data.Props["Info"] = T("api.templates.signin_change_email.body.info", - map[string]interface{}{"SiteName": es.srv.Config().TeamSettings.SiteName, "Method": method}) + map[string]interface{}{"SiteName": es.config().TeamSettings.SiteName, "Method": method}) data.Props["Warning"] = T("api.templates.email_warning") - body, err := es.srv.TemplatesContainer().RenderToString("signin_change_body", data) + body, err := es.templatesContainer.RenderToString("signin_change_body", data) if err != nil { - return model.NewAppError("SendSignInChangeEmail", "api.user.send_sign_in_change_email_and_forget.error", nil, err.Error(), http.StatusInternalServerError) + return err } if err := es.sendMail(email, subject, body); err != nil { - return model.NewAppError("SendSignInChangeEmail", "api.user.send_sign_in_change_email_and_forget.error", nil, err.Error(), http.StatusInternalServerError) + return err } return nil } -func (es *EmailService) sendWelcomeEmail(userID string, email string, verified bool, disableWelcomeEmail bool, locale, siteURL, redirect string) *model.AppError { +func (es *Service) SendWelcomeEmail(userID string, email string, verified bool, disableWelcomeEmail bool, locale, siteURL, redirect string) error { if disableWelcomeEmail { return nil } - if !*es.srv.Config().EmailSettings.SendEmailNotifications && !*es.srv.Config().EmailSettings.RequireEmailVerification { - return model.NewAppError("SendWelcomeEmail", "api.user.send_welcome_email_and_forget.failed.error", nil, "Send Email Notifications and Require Email Verification is disabled in the system console", http.StatusInternalServerError) + if !*es.config().EmailSettings.SendEmailNotifications && !*es.config().EmailSettings.RequireEmailVerification { + return errors.New("send email notifications and require email verification is disabled in the system console") } T := i18n.GetUserTranslations(locale) @@ -245,10 +179,10 @@ func (es *EmailService) sendWelcomeEmail(userID string, email string, verified b serverURL := condenseSiteURL(siteURL) subject := T("api.templates.welcome_subject", - map[string]interface{}{"SiteName": es.srv.Config().TeamSettings.SiteName, + map[string]interface{}{"SiteName": es.config().TeamSettings.SiteName, "ServerURL": serverURL}) - data := es.newEmailTemplateData(locale) + data := es.NewEmailTemplateData(locale) data.Props["SiteURL"] = siteURL data.Props["Title"] = T("api.templates.welcome_body.title") data.Props["SubTitle1"] = T("api.templates.welcome_body.subTitle1") @@ -259,14 +193,14 @@ func (es *EmailService) sendWelcomeEmail(userID string, email string, verified b data.Props["Info1"] = T("api.templates.welcome_body.info1") data.Props["SiteURL"] = siteURL - if *es.srv.Config().NativeAppSettings.AppDownloadLink != "" { + if *es.config().NativeAppSettings.AppDownloadLink != "" { data.Props["AppDownloadTitle"] = T("api.templates.welcome_body.app_download_title") data.Props["AppDownloadInfo"] = T("api.templates.welcome_body.app_download_info") data.Props["AppDownloadButton"] = T("api.templates.welcome_body.app_download_button") - data.Props["AppDownloadLink"] = *es.srv.Config().NativeAppSettings.AppDownloadLink + data.Props["AppDownloadLink"] = *es.config().NativeAppSettings.AppDownloadLink } - if !verified && *es.srv.Config().EmailSettings.RequireEmailVerification { + if !verified && *es.config().EmailSettings.RequireEmailVerification { token, err := es.CreateVerifyEmailToken(userID, email) if err != nil { return err @@ -278,23 +212,23 @@ func (es *EmailService) sendWelcomeEmail(userID string, email string, verified b data.Props["ButtonURL"] = link } - body, err := es.srv.TemplatesContainer().RenderToString("welcome_body", data) + body, err := es.templatesContainer.RenderToString("welcome_body", data) if err != nil { - return model.NewAppError("sendWelcomeEmail", "api.user.send_welcome_email_and_forget.failed.error", nil, err.Error(), http.StatusInternalServerError) + return err } if err := es.sendMail(email, subject, body); err != nil { - return model.NewAppError("sendWelcomeEmail", "api.user.send_welcome_email_and_forget.failed.error", nil, err.Error(), http.StatusInternalServerError) + return err } return nil } -func (es *EmailService) SendCloudTrialEndWarningEmail(userEmail, name, trialEndDate, locale, siteURL string) *model.AppError { +func (es *Service) SendCloudTrialEndWarningEmail(userEmail, name, trialEndDate, locale, siteURL string) error { T := i18n.GetUserTranslations(locale) subject := T("api.templates.cloud_trial_ending_email.subject") - data := es.newEmailTemplateData(locale) + data := es.NewEmailTemplateData(locale) data.Props["Title"] = T("api.templates.cloud_trial_ending_email.title") data.Props["SubTitle"] = T("api.templates.cloud_trial_ending_email.subtitle", map[string]interface{}{"Name": name, "TrialEnd": trialEndDate}) data.Props["SiteURL"] = siteURL @@ -303,25 +237,25 @@ func (es *EmailService) SendCloudTrialEndWarningEmail(userEmail, name, trialEndD data.Props["QuestionTitle"] = T("api.templates.questions_footer.title") data.Props["QuestionInfo"] = T("api.templates.questions_footer.info") - body, err := es.srv.TemplatesContainer().RenderToString("cloud_trial_end_warning", data) + body, err := es.templatesContainer.RenderToString("cloud_trial_end_warning", data) if err != nil { - return model.NewAppError("SendCloudTrialEndWarningEmail", "api.user.cloud_trial_ending_email.error", nil, err.Error(), http.StatusInternalServerError) + return err } if err := es.sendMail(userEmail, subject, body); err != nil { - return model.NewAppError("SendCloudTrialEndWarningEmail", "api.user.cloud_trial_ending_email.error", nil, err.Error(), http.StatusInternalServerError) + return err } return nil } -func (es *EmailService) SendCloudTrialEndedEmail(userEmail, name, locale, siteURL string) *model.AppError { +func (es *Service) SendCloudTrialEndedEmail(userEmail, name, locale, siteURL string) error { T := i18n.GetUserTranslations(locale) subject := T("api.templates.cloud_trial_ended_email.subject") t := time.Now() todayDate := fmt.Sprintf("%s %d, %d", t.Month(), t.Day(), t.Year()) - data := es.newEmailTemplateData(locale) + data := es.NewEmailTemplateData(locale) data.Props["Title"] = T("api.templates.cloud_trial_ended_email.title") data.Props["SubTitle"] = T("api.templates.cloud_trial_ended_email.subtitle", map[string]interface{}{"Name": name, "TodayDate": todayDate}) data.Props["SiteURL"] = siteURL @@ -330,23 +264,23 @@ func (es *EmailService) SendCloudTrialEndedEmail(userEmail, name, locale, siteUR data.Props["QuestionTitle"] = T("api.templates.questions_footer.title") data.Props["QuestionInfo"] = T("api.templates.questions_footer.info") - body, err := es.srv.TemplatesContainer().RenderToString("cloud_trial_ended_email", data) + body, err := es.templatesContainer.RenderToString("cloud_trial_ended_email", data) if err != nil { - return model.NewAppError("SendCloudTrialEndedEmail", "api.user.cloud_trial_ended_email.error", nil, err.Error(), http.StatusInternalServerError) + return err } if err := es.sendMail(userEmail, subject, body); err != nil { - return model.NewAppError("SendCloudTrialEndedEmail", "api.user.cloud_trial_ended_email.error", nil, err.Error(), http.StatusInternalServerError) + return err } return nil } // SendCloudWelcomeEmail sends the cloud version of the welcome email -func (es *EmailService) SendCloudWelcomeEmail(userEmail, locale, teamInviteID, workSpaceName, dns, siteURL string) *model.AppError { +func (es *Service) SendCloudWelcomeEmail(userEmail, locale, teamInviteID, workSpaceName, dns, siteURL string) error { T := i18n.GetUserTranslations(locale) subject := T("api.templates.cloud_welcome_email.subject") - data := es.newEmailTemplateData(locale) + data := es.NewEmailTemplateData(locale) data.Props["Title"] = T("api.templates.cloud_welcome_email.title", map[string]interface{}{"WorkSpace": workSpaceName}) data.Props["SubTitle"] = T("api.templates.cloud_welcome_email.subtitle") data.Props["SubTitleInfo"] = T("api.templates.cloud_welcome_email.subtitle_info") @@ -369,78 +303,78 @@ func (es *EmailService) SendCloudWelcomeEmail(userEmail, locale, teamInviteID, w data.Props["Button"] = T("api.templates.cloud_welcome_email.button") data.Props["GettingStartedQuestions"] = T("api.templates.cloud_welcome_email.start_questions") - body, err := es.srv.TemplatesContainer().RenderToString("cloud_welcome_email", data) + body, err := es.templatesContainer.RenderToString("cloud_welcome_email", data) if err != nil { - return model.NewAppError("SendCloudWelcomeEmail", "api.user.send_cloud_welcome_email.error", nil, err.Error(), http.StatusInternalServerError) + return err } if err := es.sendMail(userEmail, subject, body); err != nil { - return model.NewAppError("SendCloudWelcomeEmail", "api.user.send_cloud_welcome_email.error", nil, err.Error(), http.StatusInternalServerError) + return err } return nil } -func (es *EmailService) sendPasswordChangeEmail(email, method, locale, siteURL string) *model.AppError { +func (es *Service) SendPasswordChangeEmail(email, method, locale, siteURL string) error { T := i18n.GetUserTranslations(locale) subject := T("api.templates.password_change_subject", - map[string]interface{}{"SiteName": es.srv.Config().TeamSettings.SiteName, - "TeamDisplayName": es.srv.Config().TeamSettings.SiteName}) + map[string]interface{}{"SiteName": es.config().TeamSettings.SiteName, + "TeamDisplayName": es.config().TeamSettings.SiteName}) - data := es.newEmailTemplateData(locale) + data := es.NewEmailTemplateData(locale) data.Props["SiteURL"] = siteURL data.Props["Title"] = T("api.templates.password_change_body.title") data.Props["Info"] = T("api.templates.password_change_body.info", - map[string]interface{}{"TeamDisplayName": es.srv.Config().TeamSettings.SiteName, "TeamURL": siteURL, "Method": method}) + map[string]interface{}{"TeamDisplayName": es.config().TeamSettings.SiteName, "TeamURL": siteURL, "Method": method}) data.Props["Warning"] = T("api.templates.email_warning") - body, err := es.srv.TemplatesContainer().RenderToString("password_change_body", data) + body, err := es.templatesContainer.RenderToString("password_change_body", data) if err != nil { - return model.NewAppError("sendPasswordChangeEmail", "api.user.send_password_change_email_and_forget.error", nil, err.Error(), http.StatusInternalServerError) + return err } if err := es.sendMail(email, subject, body); err != nil { - return model.NewAppError("sendPasswordChangeEmail", "api.user.send_password_change_email_and_forget.error", nil, err.Error(), http.StatusInternalServerError) + return err } return nil } -func (es *EmailService) sendUserAccessTokenAddedEmail(email, locale, siteURL string) *model.AppError { +func (es *Service) SendUserAccessTokenAddedEmail(email, locale, siteURL string) error { T := i18n.GetUserTranslations(locale) subject := T("api.templates.user_access_token_subject", - map[string]interface{}{"SiteName": es.srv.Config().TeamSettings.SiteName}) + map[string]interface{}{"SiteName": es.config().TeamSettings.SiteName}) - data := es.newEmailTemplateData(locale) + data := es.NewEmailTemplateData(locale) data.Props["SiteURL"] = siteURL data.Props["Title"] = T("api.templates.user_access_token_body.title") data.Props["Info"] = T("api.templates.user_access_token_body.info", - map[string]interface{}{"SiteName": es.srv.Config().TeamSettings.SiteName, "SiteURL": siteURL}) + map[string]interface{}{"SiteName": es.config().TeamSettings.SiteName, "SiteURL": siteURL}) data.Props["Warning"] = T("api.templates.email_warning") - body, err := es.srv.TemplatesContainer().RenderToString("password_change_body", data) + body, err := es.templatesContainer.RenderToString("password_change_body", data) if err != nil { - return model.NewAppError("sendUserAccessTokenAddedEmail", "api.user.send_user_access_token.error", nil, err.Error(), http.StatusInternalServerError) + return err } if err := es.sendMail(email, subject, body); err != nil { - return model.NewAppError("sendUserAccessTokenAddedEmail", "api.user.send_user_access_token.error", nil, err.Error(), http.StatusInternalServerError) + return err } return nil } -func (es *EmailService) SendPasswordResetEmail(email string, token *model.Token, locale, siteURL string) (bool, *model.AppError) { +func (es *Service) SendPasswordResetEmail(email string, token *model.Token, locale, siteURL string) (bool, error) { T := i18n.GetUserTranslations(locale) link := fmt.Sprintf("%s/reset_password_complete?token=%s", siteURL, url.QueryEscape(token.Token)) subject := T("api.templates.reset_subject", - map[string]interface{}{"SiteName": es.srv.Config().TeamSettings.SiteName}) + map[string]interface{}{"SiteName": es.config().TeamSettings.SiteName}) - data := es.newEmailTemplateData(locale) + data := es.NewEmailTemplateData(locale) data.Props["SiteURL"] = siteURL data.Props["Title"] = T("api.templates.reset_body.title") data.Props["SubTitle"] = T("api.templates.reset_body.subTitle") @@ -450,25 +384,25 @@ func (es *EmailService) SendPasswordResetEmail(email string, token *model.Token, data.Props["QuestionTitle"] = T("api.templates.questions_footer.title") data.Props["QuestionInfo"] = T("api.templates.questions_footer.info") - body, err := es.srv.TemplatesContainer().RenderToString("reset_body", data) + body, err := es.templatesContainer.RenderToString("reset_body", data) if err != nil { - return false, model.NewAppError("SendPasswordReset", "api.user.send_password_reset.send.app_error", nil, "err="+err.Error(), http.StatusInternalServerError) + return false, err } if err := es.sendMail(email, subject, body); err != nil { - return false, model.NewAppError("SendPasswordReset", "api.user.send_password_reset.send.app_error", nil, "err="+err.Error(), http.StatusInternalServerError) + return false, err } return true, nil } -func (es *EmailService) sendMfaChangeEmail(email string, activated bool, locale, siteURL string) *model.AppError { +func (es *Service) SendMfaChangeEmail(email string, activated bool, locale, siteURL string) error { T := i18n.GetUserTranslations(locale) subject := T("api.templates.mfa_change_subject", - map[string]interface{}{"SiteName": es.srv.Config().TeamSettings.SiteName}) + map[string]interface{}{"SiteName": es.config().TeamSettings.SiteName}) - data := es.newEmailTemplateData(locale) + data := es.NewEmailTemplateData(locale) data.Props["SiteURL"] = siteURL if activated { @@ -480,33 +414,31 @@ func (es *EmailService) sendMfaChangeEmail(email string, activated bool, locale, } data.Props["Warning"] = T("api.templates.email_warning") - body, err := es.srv.TemplatesContainer().RenderToString("mfa_change_body", data) + body, err := es.templatesContainer.RenderToString("mfa_change_body", data) if err != nil { - return model.NewAppError("SendMfaChangeEmail", "api.user.send_mfa_change_email.error", nil, err.Error(), http.StatusInternalServerError) + return err } if err := es.sendMail(email, subject, body); err != nil { - return model.NewAppError("SendMfaChangeEmail", "api.user.send_mfa_change_email.error", nil, err.Error(), http.StatusInternalServerError) + return err } return nil } -func (es *EmailService) SendInviteEmails(team *model.Team, senderName string, senderUserId string, invites []string, siteURL string) *model.AppError { +func (es *Service) SendInviteEmails(team *model.Team, senderName string, senderUserId string, invites []string, siteURL string) error { if es.PerHourEmailRateLimiter == nil { - return model.NewAppError("SendInviteEmails", "app.email.no_rate_limiter.app_error", nil, fmt.Sprintf("user_id=%s, team_id=%s", senderUserId, team.Id), http.StatusInternalServerError) + return NoRateLimiterError } rateLimited, result, err := es.PerHourEmailRateLimiter.RateLimit(senderUserId, len(invites)) if err != nil { - return model.NewAppError("SendInviteEmails", "app.email.setup_rate_limiter.app_error", nil, fmt.Sprintf("user_id=%s, team_id=%s, error=%v", senderUserId, team.Id, err), http.StatusInternalServerError) + return SetupRateLimiterError } if rateLimited { - return model.NewAppError("SendInviteEmails", - "app.email.rate_limit_exceeded.app_error", map[string]interface{}{"RetryAfter": result.RetryAfter.String(), "ResetAfter": result.ResetAfter.String()}, - fmt.Sprintf("user_id=%s, team_id=%s, retry_after_secs=%f, reset_after_secs=%f", - senderUserId, team.Id, result.RetryAfter.Seconds(), result.ResetAfter.Seconds()), - http.StatusRequestEntityTooLarge) + 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 RateLimitExceededError } for _, invite := range invites { @@ -514,9 +446,9 @@ func (es *EmailService) SendInviteEmails(team *model.Team, senderName string, se subject := i18n.T("api.templates.invite_subject", map[string]interface{}{"SenderName": senderName, "TeamDisplayName": team.DisplayName, - "SiteName": es.srv.Config().TeamSettings.SiteName}) + "SiteName": es.config().TeamSettings.SiteName}) - data := es.newEmailTemplateData("") + data := es.NewEmailTemplateData("") data.Props["SiteURL"] = siteURL data.Props["Title"] = i18n.T("api.templates.invite_body.title", map[string]interface{}{"SenderName": senderName, "TeamDisplayName": team.DisplayName}) data.Props["SubTitle"] = i18n.T("api.templates.invite_body.subTitle") @@ -537,13 +469,13 @@ func (es *EmailService) SendInviteEmails(team *model.Team, senderName string, se tokenProps["name"] = team.Name tokenData := model.MapToJson(tokenProps) - if err := es.srv.Store.Token().Save(token); err != nil { + 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)) - body, err := es.srv.TemplatesContainer().RenderToString("invite_body", data) + body, err := es.templatesContainer.RenderToString("invite_body", data) if err != nil { mlog.Error("Failed to send invite email successfully ", mlog.Err(err)) } @@ -556,21 +488,19 @@ func (es *EmailService) SendInviteEmails(team *model.Team, senderName string, se return nil } -func (es *EmailService) sendGuestInviteEmails(team *model.Team, channels []*model.Channel, senderName string, senderUserId string, senderProfileImage []byte, invites []string, siteURL string, message string) *model.AppError { +func (es *Service) SendGuestInviteEmails(team *model.Team, channels []*model.Channel, senderName string, senderUserId string, senderProfileImage []byte, invites []string, siteURL string, message string) error { if es.PerHourEmailRateLimiter == nil { - return model.NewAppError("SendInviteEmails", "app.email.no_rate_limiter.app_error", nil, fmt.Sprintf("user_id=%s, team_id=%s", senderUserId, team.Id), http.StatusInternalServerError) + return NoRateLimiterError } rateLimited, result, err := es.PerHourEmailRateLimiter.RateLimit(senderUserId, len(invites)) if err != nil { - return model.NewAppError("SendInviteEmails", "app.email.setup_rate_limiter.app_error", nil, fmt.Sprintf("user_id=%s, team_id=%s, error=%v", senderUserId, team.Id, err), http.StatusInternalServerError) + return SetupRateLimiterError } if rateLimited { - return model.NewAppError("SendInviteEmails", - "app.email.rate_limit_exceeded.app_error", map[string]interface{}{"RetryAfter": result.RetryAfter.String(), "ResetAfter": result.ResetAfter.String()}, - fmt.Sprintf("user_id=%s, team_id=%s, retry_after_secs=%f, reset_after_secs=%f", - senderUserId, team.Id, result.RetryAfter.Seconds(), result.ResetAfter.Seconds()), - http.StatusRequestEntityTooLarge) + 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 RateLimitExceededError } for _, invite := range invites { @@ -578,9 +508,9 @@ func (es *EmailService) sendGuestInviteEmails(team *model.Team, channels []*mode subject := i18n.T("api.templates.invite_guest_subject", map[string]interface{}{"SenderName": senderName, "TeamDisplayName": team.DisplayName, - "SiteName": es.srv.Config().TeamSettings.SiteName}) + "SiteName": es.config().TeamSettings.SiteName}) - data := es.newEmailTemplateData("") + data := es.NewEmailTemplateData("") data.Props["SiteURL"] = siteURL data.Props["Title"] = i18n.T("api.templates.invite_body.title", map[string]interface{}{"SenderName": senderName, "TeamDisplayName": team.DisplayName}) data.Props["SubTitle"] = i18n.T("api.templates.invite_body_guest.subTitle") @@ -615,13 +545,13 @@ func (es *EmailService) sendGuestInviteEmails(team *model.Team, channels []*mode tokenProps["name"] = team.Name tokenData := model.MapToJson(tokenProps) - if err := es.srv.Store.Token().Save(token); err != nil { + 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)) - if !*es.srv.Config().EmailSettings.SendEmailNotifications { + if !*es.config().EmailSettings.SendEmailNotifications { mlog.Info("sending invitation ", mlog.String("to", invite), mlog.String("link", data.Props["ButtomURL"].(string))) } @@ -635,12 +565,12 @@ func (es *EmailService) sendGuestInviteEmails(team *model.Team, channels []*mode } } - body, err := es.srv.TemplatesContainer().RenderToString("invite_body", data) + 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 { + if nErr := es.SendMailWithEmbeddedFiles(invite, subject, body, embeddedFiles); nErr != nil { mlog.Error("Failed to send invite email successfully", mlog.Err(nErr)) } } @@ -648,7 +578,7 @@ func (es *EmailService) sendGuestInviteEmails(team *model.Team, channels []*mode return nil } -func (es *EmailService) newEmailTemplateData(locale string) templates.Data { +func (es *Service) NewEmailTemplateData(locale string) templates.Data { var localT i18n.TranslateFunc if locale != "" { localT = i18n.GetUserTranslations(locale) @@ -657,8 +587,8 @@ func (es *EmailService) newEmailTemplateData(locale string) templates.Data { } organization := "" - if *es.srv.Config().EmailSettings.FeedbackOrganization != "" { - organization = localT("api.templates.email_organization") + *es.srv.Config().EmailSettings.FeedbackOrganization + if *es.config().EmailSettings.FeedbackOrganization != "" { + organization = localT("api.templates.email_organization") + *es.config().EmailSettings.FeedbackOrganization } return templates.Data{ @@ -666,8 +596,8 @@ func (es *EmailService) newEmailTemplateData(locale string) templates.Data { "EmailInfo1": localT("api.templates.email_info1"), "EmailInfo2": localT("api.templates.email_info2"), "EmailInfo3": localT("api.templates.email_info3", - map[string]interface{}{"SiteName": es.srv.Config().TeamSettings.SiteName}), - "SupportEmail": *es.srv.Config().SupportSettings.SupportEmail, + map[string]interface{}{"SiteName": es.config().TeamSettings.SiteName}), + "SupportEmail": *es.config().SupportSettings.SupportEmail, "Footer": localT("api.templates.email_footer"), "FooterV2": localT("api.templates.email_footer_v2"), "Organization": organization, @@ -676,91 +606,61 @@ func (es *EmailService) newEmailTemplateData(locale string) templates.Data { } } -func (es *EmailService) SendDeactivateAccountEmail(email string, locale, siteURL string) *model.AppError { +func (es *Service) SendDeactivateAccountEmail(email string, locale, siteURL string) error { T := i18n.GetUserTranslations(locale) serverURL := condenseSiteURL(siteURL) subject := T("api.templates.deactivate_subject", - map[string]interface{}{"SiteName": es.srv.Config().TeamSettings.SiteName, + map[string]interface{}{"SiteName": es.config().TeamSettings.SiteName, "ServerURL": serverURL}) - data := es.newEmailTemplateData(locale) + data := es.NewEmailTemplateData(locale) data.Props["SiteURL"] = siteURL data.Props["Title"] = T("api.templates.deactivate_body.title", map[string]interface{}{"ServerURL": serverURL}) data.Props["Info"] = T("api.templates.deactivate_body.info", map[string]interface{}{"SiteURL": siteURL}) data.Props["Warning"] = T("api.templates.deactivate_body.warning") - body, err := es.srv.TemplatesContainer().RenderToString("deactivate_body", data) - if err != nil { - return model.NewAppError("SendDeactivateEmail", "api.user.send_deactivate_email_and_forget.failed.error", nil, err.Error(), http.StatusInternalServerError) - } - - if err := es.sendMail(email, subject, body); err != nil { - return model.NewAppError("SendDeactivateEmail", "api.user.send_deactivate_email_and_forget.failed.error", nil, err.Error(), http.StatusInternalServerError) - } - - return nil -} - -// SendRemoveExpiredLicenseEmail formats an email and uses the email service to send the email to user with link pointing to CWS -// to renew the user license -func (es *EmailService) SendRemoveExpiredLicenseEmail(email string, locale, siteURL string) *model.AppError { - renewalLink, err := es.srv.GenerateLicenseRenewalLink() + body, err := es.templatesContainer.RenderToString("deactivate_body", data) if err != nil { return err } - T := i18n.GetUserTranslations(locale) - subject := T("api.templates.remove_expired_license.subject", - map[string]interface{}{"SiteName": es.srv.Config().TeamSettings.SiteName}) - - data := es.newEmailTemplateData(locale) - data.Props["SiteURL"] = siteURL - data.Props["Title"] = T("api.templates.remove_expired_license.body.title") - data.Props["Link"] = renewalLink - data.Props["LinkButton"] = T("api.templates.remove_expired_license.body.renew_button") - - body, nErr := es.srv.TemplatesContainer().RenderToString("remove_expired_license", data) - if nErr != nil { - return model.NewAppError("SendRemoveExpiredLicenseEmail", "api.license.remove_expired_license.failed.error", nil, nErr.Error(), http.StatusInternalServerError) - } - if err := es.sendMail(email, subject, body); err != nil { - return model.NewAppError("SendRemoveExpiredLicenseEmail", "api.license.remove_expired_license.failed.error", nil, err.Error(), http.StatusInternalServerError) + return err } return nil } -func (es *EmailService) sendNotificationMail(to, subject, htmlBody string) error { - if !*es.srv.Config().EmailSettings.SendEmailNotifications { +func (es *Service) SendNotificationMail(to, subject, htmlBody string) error { + if !*es.config().EmailSettings.SendEmailNotifications { return nil } return es.sendMail(to, subject, htmlBody) } -func (es *EmailService) sendMail(to, subject, htmlBody string) error { +func (es *Service) sendMail(to, subject, htmlBody string) error { return es.sendMailWithCC(to, subject, htmlBody, "") } -func (es *EmailService) sendMailWithCC(to, subject, htmlBody string, ccMail string) error { - license := es.srv.License() - mailConfig := es.srv.MailServiceConfig() +func (es *Service) sendMailWithCC(to, subject, htmlBody string, ccMail string) error { + license := es.license() + mailConfig := es.mailServiceConfig() return mail.SendMailUsingConfig(to, subject, htmlBody, mailConfig, license != nil && *license.Features.Compliance, ccMail) } -func (es *EmailService) sendMailWithEmbeddedFiles(to, subject, htmlBody string, embeddedFiles map[string]io.Reader) error { - license := es.srv.License() - mailConfig := es.srv.MailServiceConfig() +func (es *Service) SendMailWithEmbeddedFiles(to, subject, htmlBody string, embeddedFiles map[string]io.Reader) error { + license := es.license() + mailConfig := es.mailServiceConfig() return mail.SendMailWithEmbeddedFilesUsingConfig(to, subject, htmlBody, embeddedFiles, mailConfig, license != nil && *license.Features.Compliance, "") } -func (es *EmailService) InvalidateVerifyEmailTokensForUser(userID string) *model.AppError { - tokens, err := es.srv.Store.Token().GetAllTokensByType(TokenTypeVerifyEmail) +func (es *Service) InvalidateVerifyEmailTokensForUser(userID string) *model.AppError { + tokens, err := es.store.Token().GetAllTokensByType(TokenTypeVerifyEmail) if err != nil { return model.NewAppError("InvalidateVerifyEmailTokensForUser", "api.user.invalidate_verify_email_tokens.error", nil, err.Error(), http.StatusInternalServerError) } @@ -780,7 +680,7 @@ func (es *EmailService) InvalidateVerifyEmailTokensForUser(userID string) *model continue } - if err := es.srv.Store.Token().Delete(token.Token); err != nil { + if err := es.store.Token().Delete(token.Token); err != nil { appErr = model.NewAppError("InvalidateVerifyEmailTokensForUser", "api.user.invalidate_verify_email_tokens_delete.error", nil, err.Error(), http.StatusInternalServerError) } } @@ -788,7 +688,7 @@ func (es *EmailService) InvalidateVerifyEmailTokensForUser(userID string) *model return appErr } -func (es *EmailService) CreateVerifyEmailToken(userID string, newEmail string) (*model.Token, *model.AppError) { +func (es *Service) CreateVerifyEmailToken(userID string, newEmail string) (*model.Token, error) { tokenExtra := struct { UserId string Email string @@ -796,10 +696,10 @@ func (es *EmailService) CreateVerifyEmailToken(userID string, newEmail string) ( userID, newEmail, } - jsonData, err := json.Marshal(tokenExtra) + jsonData, err := json.Marshal(tokenExtra) if err != nil { - return nil, model.NewAppError("CreateVerifyEmailToken", "api.user.create_email_token.error", nil, "", http.StatusInternalServerError) + return nil, errors.Wrap(CreateEmailTokenError, err.Error()) } token := model.NewToken(TokenTypeVerifyEmail, string(jsonData)) @@ -808,25 +708,19 @@ func (es *EmailService) CreateVerifyEmailToken(userID string, newEmail string) ( return nil, err } - if err = es.srv.Store.Token().Save(token); err != nil { - var appErr *model.AppError - switch { - case errors.As(err, &appErr): - return nil, appErr - default: - return nil, model.NewAppError("CreateVerifyEmailToken", "app.recover.save.app_error", nil, err.Error(), http.StatusInternalServerError) - } + if err = es.store.Token().Save(token); err != nil { + return nil, err } return token, nil } -func (es *EmailService) SendAtUserLimitWarningEmail(email string, locale string, siteURL string) (bool, *model.AppError) { +func (es *Service) SendAtUserLimitWarningEmail(email string, locale string, siteURL string) (bool, error) { T := i18n.GetUserTranslations(locale) subject := T("api.templates.at_limit_subject") - data := es.newEmailTemplateData(locale) + data := es.NewEmailTemplateData(locale) data.Props["SiteURL"] = siteURL data.Props["Title"] = T("api.templates.at_limit_title") data.Props["Info1"] = T("api.templates.at_limit_info1") @@ -836,23 +730,23 @@ func (es *EmailService) SendAtUserLimitWarningEmail(email string, locale string, data.Props["Footer"] = T("api.templates.copyright") - body, err := es.srv.TemplatesContainer().RenderToString("reached_user_limit_body", data) + body, err := es.templatesContainer.RenderToString("reached_user_limit_body", data) if err != nil { - return false, model.NewAppError("SendAtUserLimitWarningEmail", "api.user.send_password_reset.send.app_error", nil, "err="+err.Error(), http.StatusInternalServerError) + return false, err } if err := es.sendMail(email, subject, body); err != nil { - return false, model.NewAppError("SendAtUserLimitWarningEmail", "api.user.send_password_reset.send.app_error", nil, "err="+err.Error(), http.StatusInternalServerError) + return false, err } return true, nil } -func (es *EmailService) SendLicenseUpForRenewalEmail(email, name, locale, siteURL, renewalLink string, daysToExpiration int) (bool, *model.AppError) { +func (es *Service) SendLicenseUpForRenewalEmail(email, name, locale, siteURL, renewalLink string, daysToExpiration int) error { T := i18n.GetUserTranslations(locale) subject := T("api.templates.license_up_for_renewal_subject") - data := es.newEmailTemplateData(locale) + data := es.NewEmailTemplateData(locale) data.Props["SiteURL"] = siteURL data.Props["Title"] = T("api.templates.license_up_for_renewal_title") data.Props["SubTitle"] = T("api.templates.license_up_for_renewal_subtitle", map[string]interface{}{"UserName": name, "Days": daysToExpiration}) @@ -863,25 +757,25 @@ func (es *EmailService) SendLicenseUpForRenewalEmail(email, name, locale, siteUR data.Props["QuestionTitle"] = T("api.templates.questions_footer.title") data.Props["QuestionInfo"] = T("api.templates.questions_footer.info") - body, err := es.srv.TemplatesContainer().RenderToString("license_up_for_renewal", data) + body, err := es.templatesContainer.RenderToString("license_up_for_renewal", data) if err != nil { - return false, model.NewAppError("SendLicenseUpForRenewalEmail", "api.user.send_license_up_for_renewal_email.error", nil, err.Error(), http.StatusInternalServerError) + return err } if err := es.sendMail(email, subject, body); err != nil { - return false, model.NewAppError("SendLicenseUpForRenewalEmail", "api.user.send_license_up_for_renewal_email.error", nil, err.Error(), http.StatusInternalServerError) + return err } - return true, nil + return nil } // SendUpgradeEmail formats an email template and sends an email to an admin specified in the email arg -func (es *EmailService) SendUpgradeEmail(user, email, locale, siteURL, action string) (bool, *model.AppError) { +func (es *Service) SendUpgradeEmail(user, email, locale, siteURL, action string) (bool, error) { T := i18n.GetUserTranslations(locale) subject := T("api.templates.upgrade_request_subject") - data := es.newEmailTemplateData(locale) + data := es.NewEmailTemplateData(locale) data.Props["Info5"] = T("api.templates.at_limit_info5") data.Props["BillingPath"] = "admin_console/billing/subscription" data.Props["SiteURL"] = siteURL @@ -897,24 +791,24 @@ func (es *EmailService) SendUpgradeEmail(user, email, locale, siteURL, action st data.Props["Info4"] = T("api.templates.upgrade_request_info4_2") } - body, err := es.srv.TemplatesContainer().RenderToString("cloud_upgrade_request_email", data) + body, err := es.templatesContainer.RenderToString("cloud_upgrade_request_email", data) if err != nil { - return false, model.NewAppError("SendUpgradeEmail", "api.user.send_upgrade_request_email.error", nil, err.Error(), http.StatusInternalServerError) + return false, err } if err := es.sendMail(email, subject, body); err != nil { - return false, model.NewAppError("SendUpgradeEmail", "api.user.send_upgrade_request_email.error", nil, err.Error(), http.StatusInternalServerError) + return false, err } return true, nil } -func (es *EmailService) SendOverUserLimitWarningEmail(email string, locale string, siteURL string) (bool, *model.AppError) { +func (es *Service) SendOverUserLimitWarningEmail(email string, locale string, siteURL string) (bool, error) { T := i18n.GetUserTranslations(locale) subject := T("api.templates.over_limit_subject") - data := es.newEmailTemplateData(locale) + data := es.NewEmailTemplateData(locale) data.Props["SiteURL"] = siteURL data.Props["Title"] = T("api.templates.over_limit_title") data.Props["Info1"] = T("api.templates.over_limit_info1") @@ -924,24 +818,24 @@ func (es *EmailService) SendOverUserLimitWarningEmail(email string, locale strin data.Props["Footer"] = T("api.templates.copyright") - body, err := es.srv.TemplatesContainer().RenderToString("reached_user_limit_body", data) + body, err := es.templatesContainer.RenderToString("reached_user_limit_body", data) if err != nil { - return false, model.NewAppError("SendOverUserLimitWarningEmail", "api.user.send_password_reset.send.app_error", nil, "err="+err.Error(), http.StatusInternalServerError) + return false, err } if err := es.sendMail(email, subject, body); err != nil { - return false, model.NewAppError("SendOverUserLimitWarningEmail", "api.user.send_password_reset.send.app_error", nil, "err="+err.Error(), http.StatusInternalServerError) + return false, err } return true, nil } -func (es *EmailService) SendOverUserLimitThirtyDayWarningEmail(email string, locale string, siteURL string) (bool, *model.AppError) { +func (es *Service) SendOverUserLimitThirtyDayWarningEmail(email string, locale string, siteURL string) (bool, error) { T := i18n.GetUserTranslations(locale) subject := T("api.templates.over_limit_30_days_subject") - data := es.newEmailTemplateData(locale) + data := es.NewEmailTemplateData(locale) data.Props["SiteURL"] = siteURL data.Props["Title"] = T("api.templates.over_limit_30_days_title") data.Props["Info1"] = T("api.templates.over_limit_30_days_info1") @@ -954,24 +848,24 @@ func (es *EmailService) SendOverUserLimitThirtyDayWarningEmail(email string, loc data.Props["Footer"] = T("api.templates.copyright") - body, err := es.srv.TemplatesContainer().RenderToString("over_user_limit_30_days_body", data) + body, err := es.templatesContainer.RenderToString("over_user_limit_30_days_body", data) if err != nil { - return false, model.NewAppError("SendOverUserLimitWarningEmail", "api.user.send_password_reset.send.app_error", nil, "err="+err.Error(), http.StatusInternalServerError) + return false, err } if err := es.sendMail(email, subject, body); err != nil { - return false, model.NewAppError("SendOverUserLimitWarningEmail", "api.user.send_password_reset.send.app_error", nil, "err="+err.Error(), http.StatusInternalServerError) + return false, err } return true, nil } -func (es *EmailService) SendOverUserLimitNinetyDayWarningEmail(email string, locale string, siteURL string, overLimitDate string) (bool, *model.AppError) { +func (es *Service) SendOverUserLimitNinetyDayWarningEmail(email string, locale string, siteURL string, overLimitDate string) (bool, error) { T := i18n.GetUserTranslations(locale) subject := T("api.templates.over_limit_90_days_subject") - data := es.newEmailTemplateData(locale) + data := es.NewEmailTemplateData(locale) data.Props["SiteURL"] = siteURL data.Props["Title"] = T("api.templates.over_limit_90_days_title") data.Props["Info1"] = T("api.templates.over_limit_90_days_info1", map[string]interface{}{"OverLimitDate": overLimitDate}) @@ -983,24 +877,24 @@ func (es *EmailService) SendOverUserLimitNinetyDayWarningEmail(email string, loc data.Props["Footer"] = T("api.templates.copyright") - body, err := es.srv.TemplatesContainer().RenderToString("over_user_limit_90_days_body", data) + body, err := es.templatesContainer.RenderToString("over_user_limit_90_days_body", data) if err != nil { - return false, model.NewAppError("SendOverUserLimitWarningEmail", "api.user.send_password_reset.send.app_error", nil, "err="+err.Error(), http.StatusInternalServerError) + return false, err } if err := es.sendMail(email, subject, body); err != nil { - return false, model.NewAppError("SendOverUserLimitWarningEmail", "api.user.send_password_reset.send.app_error", nil, "err="+err.Error(), http.StatusInternalServerError) + return false, err } return true, nil } -func (es *EmailService) SendOverUserLimitWorkspaceSuspendedWarningEmail(email string, locale string, siteURL string) (bool, *model.AppError) { +func (es *Service) SendOverUserLimitWorkspaceSuspendedWarningEmail(email string, locale string, siteURL string) (bool, error) { T := i18n.GetUserTranslations(locale) subject := T("api.templates.over_limit_suspended_subject") - data := es.newEmailTemplateData(locale) + data := es.NewEmailTemplateData(locale) data.Props["SiteURL"] = siteURL data.Props["Title"] = T("api.templates.over_limit_suspended_title") data.Props["Info1"] = T("api.templates.over_limit_suspended_info1") @@ -1010,24 +904,24 @@ func (es *EmailService) SendOverUserLimitWorkspaceSuspendedWarningEmail(email st data.Props["Footer"] = T("api.templates.copyright") - body, err := es.srv.TemplatesContainer().RenderToString("over_user_limit_workspace_suspended_body", data) + body, err := es.templatesContainer.RenderToString("over_user_limit_workspace_suspended_body", data) if err != nil { - return false, model.NewAppError("SendOverUserLimitWarningEmail", "api.user.send_password_reset.send.app_error", nil, "err="+err.Error(), http.StatusInternalServerError) + return false, err } if err := es.sendMail(email, subject, body); err != nil { - return false, model.NewAppError("SendOverUserLimitWarningEmail", "api.user.send_password_reset.send.app_error", nil, "err="+err.Error(), http.StatusInternalServerError) + return false, err } return true, nil } -func (es *EmailService) SendOverUserFourteenDayWarningEmail(email string, locale string, siteURL string, overLimitDate string) (bool, *model.AppError) { +func (es *Service) SendOverUserFourteenDayWarningEmail(email string, locale string, siteURL string, overLimitDate string) (bool, error) { T := i18n.GetUserTranslations(locale) subject := T("api.templates.over_limit_14_days_subject") - data := es.newEmailTemplateData(locale) + data := es.NewEmailTemplateData(locale) data.Props["SiteURL"] = siteURL data.Props["Title"] = T("api.templates.over_limit_14_days_title") data.Props["Info1"] = T("api.templates.over_limit_14_days_info1", map[string]interface{}{"OverLimitDate": overLimitDate}) @@ -1036,24 +930,24 @@ func (es *EmailService) SendOverUserFourteenDayWarningEmail(email string, locale data.Props["Footer"] = T("api.templates.copyright") - body, err := es.srv.TemplatesContainer().RenderToString("over_user_limit_7_days_body", data) + body, err := es.templatesContainer.RenderToString("over_user_limit_7_days_body", data) if err != nil { - return false, model.NewAppError("SendOverUserLimitWarningEmail", "api.user.send_password_reset.send.app_error", nil, "err="+err.Error(), http.StatusInternalServerError) + return false, err } if err := es.sendMail(email, subject, body); err != nil { - return false, model.NewAppError("SendOverUserLimitWarningEmail", "api.user.send_password_reset.send.app_error", nil, "err="+err.Error(), http.StatusInternalServerError) + return false, err } return true, nil } -func (es *EmailService) SendOverUserSevenDayWarningEmail(email string, locale string, siteURL string) (bool, *model.AppError) { +func (es *Service) SendOverUserSevenDayWarningEmail(email string, locale string, siteURL string) (bool, error) { T := i18n.GetUserTranslations(locale) subject := T("api.templates.over_limit_7_days_subject") - data := es.newEmailTemplateData(locale) + data := es.NewEmailTemplateData(locale) data.Props["SiteURL"] = siteURL data.Props["Title"] = T("api.templates.over_limit_7_days_title") data.Props["Info1"] = T("api.templates.over_limit_7_days_info1") @@ -1062,23 +956,23 @@ func (es *EmailService) SendOverUserSevenDayWarningEmail(email string, locale st data.Props["Footer"] = T("api.templates.copyright") - body, err := es.srv.TemplatesContainer().RenderToString("over_user_limit_7_days_body", data) + body, err := es.templatesContainer.RenderToString("over_user_limit_7_days_body", data) if err != nil { - return false, model.NewAppError("SendOverUserLimitWarningEmail", "api.user.send_password_reset.send.app_error", nil, "err="+err.Error(), http.StatusInternalServerError) + return false, err } if err := es.sendMail(email, subject, body); err != nil { - return false, model.NewAppError("SendOverUserLimitWarningEmail", "api.user.send_password_reset.send.app_error", nil, "err="+err.Error(), http.StatusInternalServerError) + return false, err } return true, nil } -func (es *EmailService) SendSuspensionEmailToSupport(email string, installationID string, customerID string, subscriptionID string, siteURL string, userCount int64) (bool, *model.AppError) { +func (es *Service) SendSuspensionEmailToSupport(email string, installationID string, customerID string, subscriptionID string, siteURL string, userCount int64) (bool, error) { // Localization not needed subject := fmt.Sprintf("Cloud Installation %s Scheduled Suspension", installationID) - data := es.newEmailTemplateData("en") + data := es.NewEmailTemplateData("en") data.Props["CustomerID"] = customerID data.Props["SiteURL"] = siteURL data.Props["SubscriptionID"] = subscriptionID @@ -1086,24 +980,24 @@ func (es *EmailService) SendSuspensionEmailToSupport(email string, installationI data.Props["SuspensionDate"] = time.Now().AddDate(0, 0, 61).Format("2006-01-02") data.Props["UserCount"] = userCount - body, err := es.srv.TemplatesContainer().RenderToString("over_user_limit_support_body", data) + body, err := es.templatesContainer.RenderToString("over_user_limit_support_body", data) if err != nil { - return false, model.NewAppError("SendOverUserLimitWarningEmail", "api.user.send_password_reset.send.app_error", nil, "err="+err.Error(), http.StatusInternalServerError) + return false, err } if err := es.sendMail(email, subject, body); err != nil { - return false, model.NewAppError("SendOverUserLimitWarningEmail", "api.user.send_password_reset.send.app_error", nil, "err="+err.Error(), http.StatusInternalServerError) + return false, err } return true, nil } -func (es *EmailService) SendPaymentFailedEmail(email string, locale string, failedPayment *model.FailedPayment, siteURL string) (bool, *model.AppError) { +func (es *Service) SendPaymentFailedEmail(email string, locale string, failedPayment *model.FailedPayment, siteURL string) (bool, error) { T := i18n.GetUserTranslations(locale) subject := T("api.templates.payment_failed.subject") - data := es.newEmailTemplateData(locale) + data := es.NewEmailTemplateData(locale) data.Props["SiteURL"] = siteURL data.Props["Title"] = T("api.templates.payment_failed.title") data.Props["Info1"] = T("api.templates.payment_failed.info1", map[string]interface{}{"CardBrand": failedPayment.CardBrand, "LastFour": failedPayment.LastFour}) @@ -1116,24 +1010,24 @@ func (es *EmailService) SendPaymentFailedEmail(email string, locale string, fail data.Props["FailedReason"] = failedPayment.FailureMessage - body, err := es.srv.TemplatesContainer().RenderToString("payment_failed_body", data) + body, err := es.templatesContainer.RenderToString("payment_failed_body", data) if err != nil { - return false, model.NewAppError("SendPaymentFailedEmail", "api.user.send_password_reset.send.app_error", nil, "err="+err.Error(), http.StatusInternalServerError) + return false, err } if err := es.sendMail(email, subject, body); err != nil { - return false, model.NewAppError("SendPaymentFailedEmail", "api.user.send_password_reset.send.app_error", nil, "err="+err.Error(), http.StatusInternalServerError) + return false, err } return true, nil } -func (es *EmailService) SendNoCardPaymentFailedEmail(email string, locale string, siteURL string) *model.AppError { +func (es *Service) SendNoCardPaymentFailedEmail(email string, locale string, siteURL string) error { T := i18n.GetUserTranslations(locale) subject := T("api.templates.payment_failed_no_card.subject") - data := es.newEmailTemplateData(locale) + data := es.NewEmailTemplateData(locale) data.Props["SiteURL"] = siteURL data.Props["Title"] = T("api.templates.payment_failed_no_card.title") data.Props["Info1"] = T("api.templates.payment_failed_no_card.info1") @@ -1143,13 +1037,38 @@ func (es *EmailService) SendNoCardPaymentFailedEmail(email string, locale string data.Props["Footer"] = T("api.templates.copyright") - body, err := es.srv.TemplatesContainer().RenderToString("payment_failed_no_card_body", data) + body, err := es.templatesContainer.RenderToString("payment_failed_no_card_body", data) if err != nil { - return model.NewAppError("SendPaymentFailedEmail", "api.user.send_password_reset.send.app_error", nil, "err="+err.Error(), http.StatusInternalServerError) + return err } if err := es.sendMail(email, subject, body); err != nil { - return model.NewAppError("SendPaymentFailedEmail", "api.user.send_password_reset.send.app_error", nil, "err="+err.Error(), http.StatusInternalServerError) + return err + } + + return nil +} + +// SendRemoveExpiredLicenseEmail formats an email and uses the email service to send the email to user with link pointing to CWS +// to renew the user license +func (es *Service) SendRemoveExpiredLicenseEmail(renewalLink, email string, locale, siteURL string) error { + T := i18n.GetUserTranslations(locale) + subject := T("api.templates.remove_expired_license.subject", + map[string]interface{}{"SiteName": es.config().TeamSettings.SiteName}) + + data := es.NewEmailTemplateData(locale) + data.Props["SiteURL"] = siteURL + data.Props["Title"] = T("api.templates.remove_expired_license.body.title") + data.Props["Link"] = renewalLink + data.Props["LinkButton"] = T("api.templates.remove_expired_license.body.renew_button") + + body, err := es.templatesContainer.RenderToString("remove_expired_license", data) + if err != nil { + return err + } + + if err := es.sendMail(email, subject, body); err != nil { + return err } return nil diff --git a/app/email_batching.go b/app/email/email_batching.go similarity index 76% rename from app/email_batching.go rename to app/email/email_batching.go index 153e85dafb..30637baa06 100644 --- a/app/email_batching.go +++ b/app/email/email_batching.go @@ -1,11 +1,10 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -package app +package email import ( "bytes" - "context" "fmt" "html/template" "io" @@ -23,10 +22,20 @@ const ( EmailBatchingTaskName = "Email Batching" ) -func (es *EmailService) InitEmailBatching() { - if *es.srv.Config().EmailSettings.EnableEmailBatching { +type postData struct { + SenderName string + ChannelName string + Message template.HTML + MessageURL string + SenderPhoto string + PostPhoto string + Time string +} + +func (es *Service) InitEmailBatching() { + if *es.config().EmailSettings.EnableEmailBatching { if es.EmailBatching == nil { - es.EmailBatching = NewEmailBatchingJob(es, *es.srv.Config().EmailSettings.EmailBatchingBufferSize) + es.EmailBatching = NewEmailBatchingJob(es, *es.config().EmailSettings.EmailBatchingBufferSize) } // note that we don't support changing EmailBatchingBufferSize without restarting the server @@ -35,8 +44,8 @@ func (es *EmailService) InitEmailBatching() { } } -func (es *EmailService) AddNotificationEmailToBatch(user *model.User, post *model.Post, team *model.Team) *model.AppError { - if !*es.srv.Config().EmailSettings.EnableEmailBatching { +func (es *Service) AddNotificationEmailToBatch(user *model.User, post *model.Post, team *model.Team) *model.AppError { + if !*es.config().EmailSettings.EnableEmailBatching { return model.NewAppError("AddNotificationEmailToBatch", "api.email_batching.add_notification_email_to_batch.disabled.app_error", nil, "", http.StatusNotImplemented) } @@ -55,24 +64,27 @@ type batchedNotification struct { } type EmailBatchingJob struct { - server *Server + config func() *model.Config + service *Service + newNotifications chan *batchedNotification pendingNotifications map[string][]*batchedNotification task *model.ScheduledTask taskMutex sync.Mutex } -func NewEmailBatchingJob(es *EmailService, bufferSize int) *EmailBatchingJob { +func NewEmailBatchingJob(es *Service, bufferSize int) *EmailBatchingJob { return &EmailBatchingJob{ - server: es.srv, + config: es.config, + service: es, newNotifications: make(chan *batchedNotification, bufferSize), pendingNotifications: make(map[string][]*batchedNotification), } } func (job *EmailBatchingJob) Start() { - mlog.Debug("Email batching job starting. Checking for pending emails periodically.", mlog.Int("interval_in_seconds", *job.server.Config().EmailSettings.EmailBatchingInterval)) - newTask := model.CreateRecurringTask(EmailBatchingTaskName, job.CheckPendingEmails, time.Duration(*job.server.Config().EmailSettings.EmailBatchingInterval)*time.Second) + mlog.Debug("Email batching job starting. Checking for pending emails periodically.", mlog.Int("interval_in_seconds", *job.config().EmailSettings.EmailBatchingInterval)) + newTask := model.CreateRecurringTask(EmailBatchingTaskName, job.CheckPendingEmails, time.Duration(*job.config().EmailSettings.EmailBatchingInterval)*time.Second) job.taskMutex.Lock() oldTask := job.task @@ -105,7 +117,7 @@ func (job *EmailBatchingJob) CheckPendingEmails() { // it's a bit weird to pass the send email function through here, but it makes it so that we can test // without actually sending emails - job.checkPendingNotifications(time.Now(), job.server.EmailService.sendBatchedEmailNotification) + job.checkPendingNotifications(time.Now(), job.service.sendBatchedEmailNotification) mlog.Debug("Email batching job ran. Some users still have notifications pending.", mlog.Int("number_of_users", len(job.pendingNotifications))) } @@ -140,7 +152,7 @@ func (job *EmailBatchingJob) checkPendingNotifications(now time.Time, handler fu continue } - team, nErr := job.server.Store.Team().GetByName(notifications[0].teamName) + team, nErr := job.service.store.Team().GetByName(notifications[0].teamName) if nErr != nil { mlog.Error("Unable to find Team id for notification", mlog.Err(nErr)) continue @@ -152,7 +164,7 @@ func (job *EmailBatchingJob) checkPendingNotifications(now time.Time, handler fu // if the user has viewed any channels in this team since the notification was queued, delete // all queued notifications - channelMembers, err := job.server.Store.Channel().GetMembersForUser(inspectedTeamNames[notification.teamName], userID) + channelMembers, err := job.service.store.Channel().GetMembersForUser(inspectedTeamNames[notification.teamName], userID) if err != nil { mlog.Error("Unable to find ChannelMembers for user", mlog.Err(err)) continue @@ -169,7 +181,7 @@ func (job *EmailBatchingJob) checkPendingNotifications(now time.Time, handler fu // get how long we need to wait to send notifications to the user var interval int64 - preference, err := job.server.Store.Preference().Get(userID, model.PREFERENCE_CATEGORY_NOTIFICATIONS, model.PREFERENCE_NAME_EMAIL_INTERVAL) + preference, err := job.service.store.Preference().Get(userID, model.PREFERENCE_CATEGORY_NOTIFICATIONS, model.PREFERENCE_NAME_EMAIL_INTERVAL) if err != nil { // use the default batching interval if an error ocurrs while fetching user preferences interval, _ = strconv.ParseInt(model.PREFERENCE_EMAIL_INTERVAL_BATCHING_SECONDS, 10, 64) @@ -184,7 +196,7 @@ func (job *EmailBatchingJob) checkPendingNotifications(now time.Time, handler fu // send the email notification if there are notifications to send AND it's been long enough if len(job.pendingNotifications[userID]) > 0 && now.Sub(time.Unix(batchStartTime/1000, 0)) > time.Duration(interval)*time.Second { - job.server.Go(func(userID string, notifications []*batchedNotification) func() { + job.service.goFn(func(userID string, notifications []*batchedNotification) func() { return func() { handler(userID, notifications) } @@ -194,38 +206,38 @@ func (job *EmailBatchingJob) checkPendingNotifications(now time.Time, handler fu } } -func (es *EmailService) sendBatchedEmailNotification(userID string, notifications []*batchedNotification) { - user, err := es.srv.Store.User().Get(context.Background(), userID) +func (es *Service) sendBatchedEmailNotification(userID string, notifications []*batchedNotification) { + user, err := es.userService.GetUser(userID) if err != nil { mlog.Warn("Unable to find recipient for batched email notification") return } translateFunc := i18n.GetUserTranslations(user.Locale) - displayNameFormat := *es.srv.Config().TeamSettings.TeammateNameDisplay - siteURL := *es.srv.Config().ServiceSettings.SiteURL + displayNameFormat := *es.config().TeamSettings.TeammateNameDisplay + siteURL := *es.config().ServiceSettings.SiteURL postsData := make([]*postData, 0 /* len */, len(notifications) /* cap */) embeddedFiles := make(map[string]io.Reader) emailNotificationContentsType := model.EMAIL_NOTIFICATION_CONTENTS_FULL - if license := es.srv.License(); license != nil && *license.Features.EmailNotificationContents { - emailNotificationContentsType = *es.srv.Config().EmailSettings.EmailNotificationContentsType + if license := es.license(); license != nil && *license.Features.EmailNotificationContents { + emailNotificationContentsType = *es.config().EmailSettings.EmailNotificationContentsType } if emailNotificationContentsType == model.EMAIL_NOTIFICATION_CONTENTS_FULL { for i, notification := range notifications { - sender, errSender := es.srv.Store.User().Get(context.Background(), notification.post.UserId) + sender, errSender := es.userService.GetUser(notification.post.UserId) if errSender != nil { mlog.Warn("Unable to find sender of post for batched email notification") } - channel, errCh := es.srv.Store.Channel().Get(notification.post.ChannelId, true) + channel, errCh := es.store.Channel().Get(notification.post.ChannelId, true) if errCh != nil { mlog.Warn("Unable to find channel of post for batched email notification") } - senderProfileImage, _, errProfileImage := es.srv.GetProfileImage(sender) + senderProfileImage, _, errProfileImage := es.userService.GetProfileImage(sender) if errProfileImage != nil { mlog.Warn("Unable to get the sender user profile image.", mlog.String("user_id", sender.Id), mlog.Err(errProfileImage)) } @@ -254,7 +266,7 @@ func (es *EmailService) sendBatchedEmailNotification(userID string, notification SenderName: sender.GetDisplayName(displayNameFormat), Time: t, ChannelName: channel.DisplayName, - Message: template.HTML(es.srv.GetMessageForNotification(notification.post, translateFunc)), + Message: template.HTML(es.GetMessageForNotification(notification.post, translateFunc)), MessageURL: MessageURL, }) } @@ -263,18 +275,18 @@ func (es *EmailService) sendBatchedEmailNotification(userID string, notification tm := time.Unix(notifications[0].post.CreateAt/1000, 0) subject := translateFunc("api.email_batching.send_batched_email_notification.subject", len(notifications), map[string]interface{}{ - "SiteName": es.srv.Config().TeamSettings.SiteName, + "SiteName": es.config().TeamSettings.SiteName, "Year": tm.Year(), "Month": translateFunc(tm.Month().String()), "Day": tm.Day(), }) - firstSender, err := es.srv.Store.User().Get(context.Background(), notifications[0].post.UserId) + firstSender, err := es.userService.GetUser(notifications[0].post.UserId) if err != nil { mlog.Warn("Unable to find sender of post for batched email notification") } - data := es.newEmailTemplateData(user.Locale) + data := es.NewEmailTemplateData(user.Locale) data.Props["SiteURL"] = siteURL data.Props["Title"] = translateFunc("api.email_batching.send_batched_email_notification.title", len(notifications)-1, map[string]interface{}{ "SenderName": firstSender.GetDisplayName(displayNameFormat), @@ -288,12 +300,12 @@ func (es *EmailService) sendBatchedEmailNotification(userID string, notification data.Props["NotificationFooterInfoLogin"] = translateFunc("app.notification.footer.infoLogin") data.Props["NotificationFooterInfo"] = translateFunc("app.notification.footer.info") - renderedPage, renderErr := es.srv.TemplatesContainer().RenderToString("messages_notification", data) + renderedPage, renderErr := es.templatesContainer.RenderToString("messages_notification", data) if renderErr != nil { mlog.Error("Unable to render email", mlog.Err(renderErr)) } - if nErr := es.sendNotificationMail(user.Email, subject, renderedPage); nErr != nil { + if nErr := es.SendNotificationMail(user.Email, subject, renderedPage); nErr != nil { mlog.Warn("Unable to send batched email notification", mlog.String("email", user.Email), mlog.Err(nErr)) } } diff --git a/app/email_batching_test.go b/app/email/email_batching_test.go similarity index 88% rename from app/email_batching_test.go rename to app/email/email_batching_test.go index b7085e4dd9..922ee1fb64 100644 --- a/app/email_batching_test.go +++ b/app/email/email_batching_test.go @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -package app +package email import ( "context" @@ -24,7 +24,7 @@ func TestHandleNewNotifications(t *testing.T) { id3 := model.NewId() // test queueing of received posts by user - job := NewEmailBatchingJob(th.Server.EmailService, 128) + job := NewEmailBatchingJob(th.service, 128) job.handleNewNotifications() @@ -59,7 +59,7 @@ func TestHandleNewNotifications(t *testing.T) { require.Len(t, job.pendingNotifications[id3], 1, "should have received 1 post for user3") // test ordering of received posts - job = NewEmailBatchingJob(th.Server.EmailService, 128) + job = NewEmailBatchingJob(th.service, 128) job.Add(&model.User{Id: id1}, &model.Post{UserId: id1, Message: "test1"}, &model.Team{Name: "team"}) job.Add(&model.User{Id: id1}, &model.Post{UserId: id1, Message: "test2"}, &model.Team{Name: "team"}) @@ -78,7 +78,7 @@ func TestCheckPendingNotifications(t *testing.T) { th := Setup(t).InitBasic() defer th.TearDown() - job := NewEmailBatchingJob(th.Server.EmailService, 128) + job := NewEmailBatchingJob(th.service, 128) job.pendingNotifications[th.BasicUser.Id] = []*batchedNotification{ { post: &model.Post{ @@ -90,13 +90,13 @@ func TestCheckPendingNotifications(t *testing.T) { }, } - channelMember, err := th.App.Srv().Store.Channel().GetMember(context.Background(), th.BasicChannel.Id, th.BasicUser.Id) + channelMember, err := th.store.Channel().GetMember(context.Background(), th.BasicChannel.Id, th.BasicUser.Id) require.NoError(t, err) channelMember.LastViewedAt = 9999999 - _, err = th.App.Srv().Store.Channel().UpdateMember(channelMember) + _, err = th.store.Channel().UpdateMember(channelMember) require.NoError(t, err) - nErr := th.App.Srv().Store.Preference().Save(&model.Preferences{{ + nErr := th.store.Preference().Save(&model.Preferences{{ UserId: th.BasicUser.Id, Category: model.PREFERENCE_CATEGORY_NOTIFICATIONS, Name: model.PREFERENCE_NAME_EMAIL_INTERVAL, @@ -111,14 +111,14 @@ func TestCheckPendingNotifications(t *testing.T) { require.Len(t, job.pendingNotifications[th.BasicUser.Id], 1, "shouldn't have sent queued post") // test that notifications are cleared if the user has acted - channelMember, err = th.App.Srv().Store.Channel().GetMember(context.Background(), th.BasicChannel.Id, th.BasicUser.Id) + channelMember, err = th.store.Channel().GetMember(context.Background(), th.BasicChannel.Id, th.BasicUser.Id) require.NoError(t, err) channelMember.LastViewedAt = 10001000 - _, err = th.App.Srv().Store.Channel().UpdateMember(channelMember) + _, err = th.store.Channel().UpdateMember(channelMember) require.NoError(t, err) // We reset the interval to something shorter - nErr = th.App.Srv().Store.Preference().Save(&model.Preferences{{ + nErr = th.store.Preference().Save(&model.Preferences{{ UserId: th.BasicUser.Id, Category: model.PREFERENCE_CATEGORY_NOTIFICATIONS, Name: model.PREFERENCE_NAME_EMAIL_INTERVAL, @@ -197,13 +197,18 @@ func TestCheckPendingNotificationsDefaultInterval(t *testing.T) { th := Setup(t).InitBasic() defer th.TearDown() - job := NewEmailBatchingJob(th.Server.EmailService, 128) + job := NewEmailBatchingJob(th.service, 128) // bypasses recent user activity check - channelMember, err := th.App.Srv().Store.Channel().GetMember(context.Background(), th.BasicChannel.Id, th.BasicUser.Id) + require.NotNil(t, th.store) + require.NotNil(t, th.store.Channel()) + + require.NotNil(t, th.BasicUser) + require.NotNil(t, th.BasicChannel) + channelMember, err := th.store.Channel().GetMember(context.Background(), th.BasicChannel.Id, th.BasicUser.Id) require.NoError(t, err) channelMember.LastViewedAt = 9999000 - _, err = th.App.Srv().Store.Channel().UpdateMember(channelMember) + _, err = th.store.Channel().UpdateMember(channelMember) require.NoError(t, err) job.pendingNotifications[th.BasicUser.Id] = []*batchedNotification{ @@ -235,17 +240,21 @@ func TestCheckPendingNotificationsCantParseInterval(t *testing.T) { th := Setup(t).InitBasic() defer th.TearDown() - job := NewEmailBatchingJob(th.Server.EmailService, 128) + job := NewEmailBatchingJob(th.service, 128) + require.NotNil(t, th.store) + require.NotNil(t, th.store.Channel()) + require.NotNil(t, th.BasicChannel) + require.NotNil(t, th.BasicUser) // bypasses recent user activity check - channelMember, err := th.App.Srv().Store.Channel().GetMember(context.Background(), th.BasicChannel.Id, th.BasicUser.Id) + channelMember, err := th.store.Channel().GetMember(context.Background(), th.BasicChannel.Id, th.BasicUser.Id) require.NoError(t, err) channelMember.LastViewedAt = 9999000 - _, err = th.App.Srv().Store.Channel().UpdateMember(channelMember) + _, err = th.store.Channel().UpdateMember(channelMember) require.NoError(t, err) // preference value is not an integer, so we'll fall back to the default 15min value - nErr := th.App.Srv().Store.Preference().Save(&model.Preferences{{ + nErr := th.store.Preference().Save(&model.Preferences{{ UserId: th.BasicUser.Id, Category: model.PREFERENCE_CATEGORY_NOTIFICATIONS, Name: model.PREFERENCE_NAME_EMAIL_INTERVAL, diff --git a/app/email/email_test.go b/app/email/email_test.go new file mode 100644 index 0000000000..bd1aa9d182 --- /dev/null +++ b/app/email/email_test.go @@ -0,0 +1,73 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package email + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/mattermost/mattermost-server/v5/model" + "github.com/mattermost/mattermost-server/v5/shared/mail" +) + +func TestCondenseSiteURL(t *testing.T) { + require.Equal(t, "", condenseSiteURL("")) + require.Equal(t, "mattermost.com", condenseSiteURL("mattermost.com")) + require.Equal(t, "mattermost.com", condenseSiteURL("mattermost.com/")) + require.Equal(t, "chat.mattermost.com", condenseSiteURL("chat.mattermost.com")) + require.Equal(t, "chat.mattermost.com", condenseSiteURL("chat.mattermost.com/")) + require.Equal(t, "mattermost.com/subpath", condenseSiteURL("mattermost.com/subpath")) + require.Equal(t, "mattermost.com/subpath", condenseSiteURL("mattermost.com/subpath/")) + require.Equal(t, "chat.mattermost.com/subpath", condenseSiteURL("chat.mattermost.com/subpath")) + require.Equal(t, "chat.mattermost.com/subpath", condenseSiteURL("chat.mattermost.com/subpath/")) + + require.Equal(t, "mattermost.com:8080", condenseSiteURL("http://mattermost.com:8080")) + require.Equal(t, "mattermost.com:8080", condenseSiteURL("http://mattermost.com:8080/")) + require.Equal(t, "chat.mattermost.com:8080", condenseSiteURL("http://chat.mattermost.com:8080")) + require.Equal(t, "chat.mattermost.com:8080", condenseSiteURL("http://chat.mattermost.com:8080/")) + require.Equal(t, "mattermost.com:8080/subpath", condenseSiteURL("http://mattermost.com:8080/subpath")) + require.Equal(t, "mattermost.com:8080/subpath", condenseSiteURL("http://mattermost.com:8080/subpath/")) + require.Equal(t, "chat.mattermost.com:8080/subpath", condenseSiteURL("http://chat.mattermost.com:8080/subpath")) + require.Equal(t, "chat.mattermost.com:8080/subpath", condenseSiteURL("http://chat.mattermost.com:8080/subpath/")) +} + +func TestSendInviteEmails(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + th.ConfigureInbucketMail() + + th.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.EnableEmailInvitations = true + }) + + require.NotNil(t, th.BasicUser) + require.NotNil(t, th.BasicChannel) + + emailTo := "test@example.com" + mail.DeleteMailBox(emailTo) + + err := th.service.SendInviteEmails(th.BasicTeam, "test-user", th.BasicUser.Id, []string{emailTo}, "http://testserver") + require.NoError(t, err) + + var resultsMailbox mail.JSONMessageHeaderInbucket + err2 := mail.RetryInbucket(5, func() error { + var err error + resultsMailbox, err = mail.GetMailBox(emailTo) + return err + }) + if err2 != nil { + t.Log(err2) + t.Log("No email was received, maybe due load on the server. Skipping this verification") + } else if len(resultsMailbox) > 0 { + require.Len(t, resultsMailbox, 1) + require.Contains(t, resultsMailbox[0].To[0], emailTo, "Wrong To: recipient") + resultsEmail, err := mail.GetMessageFromMailbox(emailTo, resultsMailbox[0].ID) + require.NoError(t, err, "Could not get message from mailbox") + require.Contains(t, resultsEmail.Body.HTML, "http://testserver", "Wrong received message %s", resultsEmail.Body.Text) + require.Contains(t, resultsEmail.Body.HTML, "test-user", "Wrong received message %s", resultsEmail.Body.Text) + require.Contains(t, resultsEmail.Body.Text, "http://testserver", "Wrong received message %s", resultsEmail.Body.Text) + require.Contains(t, resultsEmail.Body.Text, "test-user", "Wrong received message %s", resultsEmail.Body.Text) + } +} diff --git a/app/email/errors.go b/app/email/errors.go new file mode 100644 index 0000000000..064e846c1d --- /dev/null +++ b/app/email/errors.go @@ -0,0 +1,13 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package email + +import "github.com/pkg/errors" + +var ( + CreateEmailTokenError = errors.New("could not create token") + NoRateLimiterError = errors.New("the rate limit could not be found") + SetupRateLimiterError = errors.New("the rate limiter could not be set") + RateLimitExceededError = errors.New("the rate limit is exceeded") +) diff --git a/app/email/helper_test.go b/app/email/helper_test.go new file mode 100644 index 0000000000..975a559ece --- /dev/null +++ b/app/email/helper_test.go @@ -0,0 +1,307 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package email + +import ( + "bytes" + "io/ioutil" + "os" + "path/filepath" + "testing" + + "github.com/mattermost/mattermost-server/v5/config" + "github.com/mattermost/mattermost-server/v5/model" + "github.com/mattermost/mattermost-server/v5/plugin/plugintest/mock" + "github.com/mattermost/mattermost-server/v5/services/users" + "github.com/mattermost/mattermost-server/v5/shared/mlog" + "github.com/mattermost/mattermost-server/v5/shared/templates" + "github.com/mattermost/mattermost-server/v5/store" + "github.com/mattermost/mattermost-server/v5/store/storetest/mocks" + "github.com/mattermost/mattermost-server/v5/testlib" + "github.com/mattermost/mattermost-server/v5/utils" +) + +type TestHelper struct { + service *Service + configStore *config.Store + store store.Store + workspace string + + BasicTeam *model.Team + BasicChannel *model.Channel + BasicUser *model.User + BasicUser2 *model.User + + SystemAdminUser *model.User + LogBuffer *bytes.Buffer +} + +func Setup(tb testing.TB) *TestHelper { + if testing.Short() { + tb.SkipNow() + } + dbStore := mainHelper.GetStore() + dbStore.DropAllTables() + dbStore.MarkSystemRanUnitTests() + mainHelper.PreloadMigrations() + + return setupTestHelper(dbStore, tb) +} + +func SetupWithStoreMock(tb testing.TB) *TestHelper { + mockStore := testlib.GetMockStoreForSetupFunctions() + th := setupTestHelper(mockStore, tb) + statusMock := mocks.StatusStore{} + statusMock.On("UpdateExpiredDNDStatuses").Return([]*model.Status{}, nil) + statusMock.On("Get", "user1").Return(&model.Status{UserId: "user1", Status: model.STATUS_ONLINE}, nil) + statusMock.On("UpdateLastActivityAt", "user1", mock.Anything).Return(nil) + statusMock.On("SaveOrUpdate", mock.AnythingOfType("*model.Status")).Return(nil) + emptyMockStore := mocks.Store{} + emptyMockStore.On("Close").Return(nil) + emptyMockStore.On("Status").Return(&statusMock) + th.service.store = &emptyMockStore + return th +} + +func setupTestHelper(s store.Store, tb testing.TB) *TestHelper { + tempWorkspace, err := ioutil.TempDir("", "userservicetest") + if err != nil { + panic(err) + } + + configStore := config.NewTestMemoryStore() + + config := configStore.Get() + *config.PluginSettings.Directory = filepath.Join(tempWorkspace, "plugins") + *config.PluginSettings.ClientDirectory = filepath.Join(tempWorkspace, "webapp") + *config.PluginSettings.AutomaticPrepackagedPlugins = false + *config.LogSettings.EnableSentry = false // disable error reporting during tests + *config.AnnouncementSettings.AdminNoticesEnabled = false + *config.AnnouncementSettings.UserNoticesEnabled = false + *config.TeamSettings.MaxUsersPerTeam = 50 + *config.RateLimitSettings.Enable = false + *config.TeamSettings.EnableOpenServer = true + // Disable strict password requirements for test + *config.PasswordSettings.MinimumLength = 5 + *config.PasswordSettings.Lowercase = false + *config.PasswordSettings.Uppercase = false + *config.PasswordSettings.Symbol = false + *config.PasswordSettings.Number = false + configStore.Set(config) + + licenseFn := func() *model.License { return model.NewTestLicense() } + + us, err := users.New(users.ServiceConfig{ + UserStore: s.User(), + SessionStore: s.Session(), + OAuthStore: s.OAuth(), + ConfigFn: configStore.Get, + LicenseFn: licenseFn, + }) + if err != nil { + panic(err) + } + + templatesDir, ok := templates.GetTemplateDirectory() + if !ok { + panic("failed find server templates") + } + htmlTemplateWatcher, errorsChan, err := templates.NewWithWatcher(templatesDir) + if err != nil { + panic(err) + } + + go func() { + for err2 := range errorsChan { + mlog.Error("Server templates error", mlog.Err(err2)) + } + }() + + service := &Service{ + store: s, + userService: us, + license: licenseFn, + config: configStore.Get, + templatesContainer: htmlTemplateWatcher, + goFn: func(f func()) { go f() }, + } + + if err := service.setUpRateLimiters(); err != nil { + panic(err) + } + + return &TestHelper{ + service: service, + configStore: configStore, + store: s, + LogBuffer: &bytes.Buffer{}, + workspace: tempWorkspace, + } +} + +func (th *TestHelper) InitBasic() *TestHelper { + th.BasicTeam = th.CreateTeam() + + th.SystemAdminUser = th.CreateUser() + th.SystemAdminUser, _ = th.service.userService.GetUser(th.SystemAdminUser.Id) + th.addUserToTeam(th.BasicTeam, th.SystemAdminUser) + + th.BasicUser = th.CreateUser() + th.BasicUser, _ = th.service.userService.GetUser(th.BasicUser.Id) + th.addUserToTeam(th.BasicTeam, th.BasicUser) + + th.BasicUser2 = th.CreateUser() + th.BasicUser2, _ = th.service.userService.GetUser(th.BasicUser2.Id) + th.addUserToTeam(th.BasicTeam, th.BasicUser2) + + th.BasicChannel = th.createChannel(th.BasicTeam, model.CHANNEL_OPEN) + th.addUserToChannel(th.BasicChannel, th.SystemAdminUser) + th.addUserToChannel(th.BasicChannel, th.BasicUser) + th.addUserToChannel(th.BasicChannel, th.BasicUser2) + + return th +} + +func (th *TestHelper) CreateTeam() *model.Team { + id := model.NewId() + team := &model.Team{ + DisplayName: "dn_" + id, + Name: "name" + id, + Email: "success+" + id + "@simulator.amazonses.com", + Type: model.TEAM_OPEN, + } + + utils.DisableDebugLogForTest() + var err error + if team, err = th.store.Team().Save(team); err != nil { + panic(err) + } + utils.EnableDebugLogForTest() + return team +} + +func (th *TestHelper) createChannel(team *model.Team, channelType string) *model.Channel { + id := model.NewId() + + channel := &model.Channel{ + DisplayName: "dn_" + id, + Name: "name_" + id, + Type: channelType, + TeamId: team.Id, + CreatorId: th.BasicUser.Id, + } + + utils.DisableDebugLogForTest() + var err error + if channel, err = th.store.Channel().Save(channel, *th.configStore.Get().TeamSettings.MaxChannelsPerTeam); err != nil { + panic(err) + } + + utils.EnableDebugLogForTest() + return channel +} + +func (th *TestHelper) addUserToChannel(channel *model.Channel, user *model.User) *model.ChannelMember { + newMember := &model.ChannelMember{ + ChannelId: channel.Id, + UserId: user.Id, + NotifyProps: model.GetDefaultChannelNotifyProps(), + SchemeGuest: user.IsGuest(), + SchemeUser: !user.IsGuest(), + } + + var err error + newMember, err = th.store.Channel().SaveMember(newMember) + if err != nil { + panic(err) + } + + return newMember +} + +func (th *TestHelper) addUserToTeam(team *model.Team, user *model.User) *model.TeamMember { + tm := &model.TeamMember{ + TeamId: team.Id, + UserId: user.Id, + SchemeGuest: user.IsGuest(), + SchemeUser: !user.IsGuest(), + } + + var err error + tm, err = th.store.Team().SaveMember(tm, *th.service.config().TeamSettings.MaxUsersPerTeam) + if err != nil { + panic(err) + } + + return tm +} + +func (th *TestHelper) CreateUser() *model.User { + return th.CreateUserOrGuest(false) +} + +func (th *TestHelper) CreateGuest() *model.User { + return th.CreateUserOrGuest(true) +} + +func (th *TestHelper) CreateUserOrGuest(guest bool) *model.User { + id := model.NewId() + + user := &model.User{ + Email: "success+" + id + "@simulator.amazonses.com", + Username: "un_" + id, + Nickname: "nn_" + id, + Password: "Password1", + EmailVerified: true, + } + + var err error + if guest { + if user, err = th.service.userService.CreateUser(user, users.UserCreateOptions{Guest: true}); err != nil { + panic(err) + } + } else { + if user, err = th.service.userService.CreateUser(user, users.UserCreateOptions{}); err != nil { + panic(err) + } + } + return user +} + +func (th *TestHelper) TearDown() { + th.configStore.Close() + + th.store.Close() + + if th.workspace != "" { + os.RemoveAll(th.workspace) + } +} + +func (th *TestHelper) UpdateConfig(f func(*model.Config)) { + if th.configStore.IsReadOnly() { + return + } + old := th.configStore.Get() + updated := old.Clone() + f(updated) + if _, _, err := th.configStore.Set(updated); err != nil { + panic(err) + } +} + +func (th *TestHelper) ConfigureInbucketMail() { + inbucket_host := os.Getenv("CI_INBUCKET_HOST") + if inbucket_host == "" { + inbucket_host = "localhost" + } + inbucket_port := os.Getenv("CI_INBUCKET_SMTP_PORT") + if inbucket_port == "" { + inbucket_port = "10025" + } + th.UpdateConfig(func(cfg *model.Config) { + *cfg.EmailSettings.SMTPServer = inbucket_host + *cfg.EmailSettings.SMTPPort = inbucket_port + }) +} diff --git a/app/email/main_test.go b/app/email/main_test.go new file mode 100644 index 0000000000..3ab631a950 --- /dev/null +++ b/app/email/main_test.go @@ -0,0 +1,35 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package email + +import ( + "flag" + "testing" + + "github.com/mattermost/mattermost-server/v5/shared/mlog" + "github.com/mattermost/mattermost-server/v5/testlib" +) + +var mainHelper *testlib.MainHelper +var replicaFlag bool + +func TestMain(m *testing.M) { + if f := flag.Lookup("mysql-replica"); f == nil { + flag.BoolVar(&replicaFlag, "mysql-replica", false, "") + flag.Parse() + } + + var options = testlib.HelperOptions{ + EnableStore: true, + EnableResources: true, + WithReadReplica: replicaFlag, + } + + mlog.DisableZap() + + mainHelper = testlib.NewMainHelperWithOptions(&options) + defer mainHelper.Close() + + mainHelper.Main(m) +} diff --git a/app/email/notification_email.go b/app/email/notification_email.go new file mode 100644 index 0000000000..f7e893d269 --- /dev/null +++ b/app/email/notification_email.go @@ -0,0 +1,46 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package email + +import ( + "net/url" + "path/filepath" + "strings" + + "github.com/mattermost/mattermost-server/v5/model" + "github.com/mattermost/mattermost-server/v5/shared/i18n" + "github.com/mattermost/mattermost-server/v5/shared/mlog" +) + +func (es *Service) GetMessageForNotification(post *model.Post, translateFunc i18n.TranslateFunc) string { + if strings.TrimSpace(post.Message) != "" || len(post.FileIds) == 0 { + return post.Message + } + + // extract the filenames from their paths and determine what type of files are attached + infos, err := es.store.FileInfo().GetForPost(post.Id, true, false, true) + if err != nil { + mlog.Warn("Encountered error when getting files for notification message", mlog.String("post_id", post.Id), mlog.Err(err)) + } + + filenames := make([]string, len(infos)) + onlyImages := true + for i, info := range infos { + if escaped, err := url.QueryUnescape(filepath.Base(info.Name)); err != nil { + // this should never error since filepath was escaped using url.QueryEscape + filenames[i] = escaped + } else { + filenames[i] = info.Name + } + + onlyImages = onlyImages && info.IsImage() + } + + props := map[string]interface{}{"Filenames": strings.Join(filenames, ", ")} + + if onlyImages { + return translateFunc("api.post.get_message_for_notification.images_sent", len(filenames), props) + } + return translateFunc("api.post.get_message_for_notification.files_sent", len(filenames), props) +} diff --git a/app/email/service.go b/app/email/service.go new file mode 100644 index 0000000000..e2243bbc2f --- /dev/null +++ b/app/email/service.go @@ -0,0 +1,120 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package email + +import ( + "net/url" + "path" + + "github.com/pkg/errors" + "github.com/throttled/throttled" + "github.com/throttled/throttled/store/memstore" + + "github.com/mattermost/mattermost-server/v5/model" + "github.com/mattermost/mattermost-server/v5/services/users" + "github.com/mattermost/mattermost-server/v5/shared/templates" + "github.com/mattermost/mattermost-server/v5/store" +) + +const ( + emailRateLimitingMemstoreSize = 65536 + emailRateLimitingPerHour = 20 + emailRateLimitingMaxBurst = 20 + + TokenTypePasswordRecovery = "password_recovery" + TokenTypeVerifyEmail = "verify_email" + TokenTypeTeamInvitation = "team_invitation" + TokenTypeGuestInvitation = "guest_invitation" + TokenTypeCWSAccess = "cws_access_token" +) + +func condenseSiteURL(siteURL string) string { + parsedSiteURL, _ := url.Parse(siteURL) + if parsedSiteURL.Path == "" || parsedSiteURL.Path == "/" { + return parsedSiteURL.Host + } + + return path.Join(parsedSiteURL.Host, parsedSiteURL.Path) +} + +type Service struct { + config func() *model.Config + goFn func(f func()) + license func() *model.License + + userService *users.UserService + store store.Store + + templatesContainer *templates.Container + PerHourEmailRateLimiter *throttled.GCRARateLimiter + PerDayEmailRateLimiter *throttled.GCRARateLimiter + EmailBatching *EmailBatchingJob +} + +type ServiceConfig struct { + ConfigFn func() *model.Config + LicenseFn func() *model.License + GoFn func(f func()) + + TemplatesContainer *templates.Container + UserService *users.UserService + Store store.Store +} + +func NewService(config ServiceConfig) (*Service, error) { + if err := config.validate(); err != nil { + return nil, err + } + service := &Service{ + config: config.ConfigFn, + templatesContainer: config.TemplatesContainer, + license: config.LicenseFn, + goFn: config.GoFn, + store: config.Store, + userService: config.UserService, + } + if err := service.setUpRateLimiters(); err != nil { + return nil, err + } + service.InitEmailBatching() + return service, nil +} + +func (c *ServiceConfig) validate() error { + if c.ConfigFn == nil || c.GoFn == nil || c.Store == nil || c.LicenseFn == nil || c.TemplatesContainer == nil { + return errors.New("invalid service config") + } + return nil +} + +func (es *Service) setUpRateLimiters() error { + store, err := memstore.New(emailRateLimitingMemstoreSize) + if err != nil { + return errors.Wrap(err, "Unable to setup email rate limiting memstore.") + } + + perHourQuota := throttled.RateQuota{ + MaxRate: throttled.PerHour(emailRateLimitingPerHour), + MaxBurst: emailRateLimitingMaxBurst, + } + + perDayQuota := throttled.RateQuota{ + MaxRate: throttled.PerDay(1), + MaxBurst: 0, + } + + perHourRateLimiter, err := throttled.NewGCRARateLimiter(store, perHourQuota) + if err != nil || perHourRateLimiter == nil { + return errors.Wrap(err, "Unable to setup email rate limiting GCRA rate limiter.") + } + + perDayRateLimiter, err := throttled.NewGCRARateLimiter(store, perDayQuota) + if err != nil || perDayRateLimiter == nil { + return errors.Wrap(err, "Unable to setup per day email rate limiting GCRA rate limiter.") + } + + es.PerHourEmailRateLimiter = perHourRateLimiter + es.PerDayEmailRateLimiter = perDayRateLimiter + return nil +} diff --git a/app/email/utils.go b/app/email/utils.go new file mode 100644 index 0000000000..57c8f53443 --- /dev/null +++ b/app/email/utils.go @@ -0,0 +1,31 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package email + +import ( + "github.com/mattermost/mattermost-server/v5/shared/mail" + "github.com/mattermost/mattermost-server/v5/utils" +) + +func (es *Service) mailServiceConfig() *mail.SMTPConfig { + emailSettings := es.config().EmailSettings + hostname := utils.GetHostnameFromSiteURL(*es.config().ServiceSettings.SiteURL) + cfg := mail.SMTPConfig{ + Hostname: hostname, + ConnectionSecurity: *emailSettings.ConnectionSecurity, + SkipServerCertificateVerification: *emailSettings.SkipServerCertificateVerification, + ServerName: *emailSettings.SMTPServer, + Server: *emailSettings.SMTPServer, + Port: *emailSettings.SMTPPort, + ServerTimeout: *emailSettings.SMTPServerTimeout, + Username: *emailSettings.SMTPUsername, + Password: *emailSettings.SMTPPassword, + EnableSMTPAuth: *emailSettings.EnableSMTPAuth, + SendEmailNotifications: *emailSettings.SendEmailNotifications, + FeedbackName: *emailSettings.FeedbackName, + FeedbackEmail: *emailSettings.FeedbackEmail, + ReplyToAddress: *emailSettings.ReplyToAddress, + } + return &cfg +} diff --git a/app/email_test.go b/app/email_test.go index 8f554e342f..bd78f0e8eb 100644 --- a/app/email_test.go +++ b/app/email_test.go @@ -8,34 +8,11 @@ import ( "strconv" "testing" + "github.com/mattermost/mattermost-server/v5/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - - "github.com/mattermost/mattermost-server/v5/model" - "github.com/mattermost/mattermost-server/v5/shared/mail" ) -func TestCondenseSiteURL(t *testing.T) { - require.Equal(t, "", condenseSiteURL("")) - require.Equal(t, "mattermost.com", condenseSiteURL("mattermost.com")) - require.Equal(t, "mattermost.com", condenseSiteURL("mattermost.com/")) - require.Equal(t, "chat.mattermost.com", condenseSiteURL("chat.mattermost.com")) - require.Equal(t, "chat.mattermost.com", condenseSiteURL("chat.mattermost.com/")) - require.Equal(t, "mattermost.com/subpath", condenseSiteURL("mattermost.com/subpath")) - require.Equal(t, "mattermost.com/subpath", condenseSiteURL("mattermost.com/subpath/")) - require.Equal(t, "chat.mattermost.com/subpath", condenseSiteURL("chat.mattermost.com/subpath")) - require.Equal(t, "chat.mattermost.com/subpath", condenseSiteURL("chat.mattermost.com/subpath/")) - - require.Equal(t, "mattermost.com:8080", condenseSiteURL("http://mattermost.com:8080")) - require.Equal(t, "mattermost.com:8080", condenseSiteURL("http://mattermost.com:8080/")) - require.Equal(t, "chat.mattermost.com:8080", condenseSiteURL("http://chat.mattermost.com:8080")) - require.Equal(t, "chat.mattermost.com:8080", condenseSiteURL("http://chat.mattermost.com:8080/")) - require.Equal(t, "mattermost.com:8080/subpath", condenseSiteURL("http://mattermost.com:8080/subpath")) - require.Equal(t, "mattermost.com:8080/subpath", condenseSiteURL("http://mattermost.com:8080/subpath/")) - require.Equal(t, "chat.mattermost.com:8080/subpath", condenseSiteURL("http://chat.mattermost.com:8080/subpath")) - require.Equal(t, "chat.mattermost.com:8080/subpath", condenseSiteURL("http://chat.mattermost.com:8080/subpath/")) -} - func TestSendInviteEmailRateLimits(t *testing.T) { th := Setup(t).InitBasic() defer th.TearDown() @@ -134,39 +111,3 @@ func TestSendAdminUpgradeRequestEmailOnJoin(t *testing.T) { require.NotNil(t, err) assert.Equal(t, err.Id, "app.email.rate_limit_exceeded.app_error") } - -func TestSendInviteEmails(t *testing.T) { - th := Setup(t).InitBasic() - defer th.TearDown() - th.ConfigureInbucketMail() - - th.App.UpdateConfig(func(cfg *model.Config) { - *cfg.ServiceSettings.EnableEmailInvitations = true - }) - - emailTo := "test@example.com" - mail.DeleteMailBox(emailTo) - - appErr := th.App.Srv().EmailService.SendInviteEmails(th.BasicTeam, "test-user", th.BasicUser.Id, []string{emailTo}, "http://testserver") - require.Nil(t, appErr) - - var resultsMailbox mail.JSONMessageHeaderInbucket - err2 := mail.RetryInbucket(5, func() error { - var err error - resultsMailbox, err = mail.GetMailBox(emailTo) - return err - }) - if err2 != nil { - t.Log(err2) - t.Log("No email was received, maybe due load on the server. Skipping this verification") - } else if len(resultsMailbox) > 0 { - require.Len(t, resultsMailbox, 1) - require.Contains(t, resultsMailbox[0].To[0], emailTo, "Wrong To: recipient") - resultsEmail, err := mail.GetMessageFromMailbox(emailTo, resultsMailbox[0].ID) - require.NoError(t, err, "Could not get message from mailbox") - require.Contains(t, resultsEmail.Body.HTML, "http://testserver", "Wrong received message %s", resultsEmail.Body.Text) - require.Contains(t, resultsEmail.Body.HTML, "test-user", "Wrong received message %s", resultsEmail.Body.Text) - require.Contains(t, resultsEmail.Body.Text, "http://testserver", "Wrong received message %s", resultsEmail.Body.Text) - require.Contains(t, resultsEmail.Body.Text, "test-user", "Wrong received message %s", resultsEmail.Body.Text) - } -} diff --git a/app/notification_email.go b/app/notification_email.go index f312ebd242..8ea85f689f 100644 --- a/app/notification_email.go +++ b/app/notification_email.go @@ -9,8 +9,6 @@ import ( "html" "html/template" "io" - "net/url" - "path/filepath" "strings" "time" @@ -114,7 +112,7 @@ func (a *App) sendNotificationEmail(notification *PostNotification, user *model. } a.Srv().Go(func() { - if nErr := a.Srv().EmailService.sendMailWithEmbeddedFiles(user.Email, html.UnescapeString(subjectText), bodyText, embeddedFiles); nErr != nil { + if nErr := a.Srv().EmailService.SendMailWithEmbeddedFiles(user.Email, html.UnescapeString(subjectText), bodyText, embeddedFiles); nErr != nil { mlog.Error("Error while sending the email", mlog.String("user_email", user.Email), mlog.Err(nErr)) } }) @@ -212,7 +210,7 @@ func (a *App) getNotificationEmailBody(recipient *model.User, post *model.Post, pData.Time = translateFunc("app.notification.body.dm.time", messageTime) } - data := a.Srv().EmailService.newEmailTemplateData(recipient.Locale) + data := a.Srv().EmailService.NewEmailTemplateData(recipient.Locale) data.Props["SiteURL"] = a.GetSiteURL() if teamName != "select_team" { data.Props["ButtonURL"] = landingURL + "/pl/" + post.Id @@ -321,38 +319,6 @@ func (a *App) generateHyperlinkForChannels(postMessage, teamName, teamURL string return postMessage, nil } -func (s *Server) GetMessageForNotification(post *model.Post, translateFunc i18n.TranslateFunc) string { - if strings.TrimSpace(post.Message) != "" || len(post.FileIds) == 0 { - return post.Message - } - - // extract the filenames from their paths and determine what type of files are attached - infos, err := s.Store.FileInfo().GetForPost(post.Id, true, false, true) - if err != nil { - mlog.Warn("Encountered error when getting files for notification message", mlog.String("post_id", post.Id), mlog.Err(err)) - } - - filenames := make([]string, len(infos)) - onlyImages := true - for i, info := range infos { - if escaped, err := url.QueryUnescape(filepath.Base(info.Name)); err != nil { - // this should never error since filepath was escaped using url.QueryEscape - filenames[i] = escaped - } else { - filenames[i] = info.Name - } - - onlyImages = onlyImages && info.IsImage() - } - - props := map[string]interface{}{"Filenames": strings.Join(filenames, ", ")} - - if onlyImages { - return translateFunc("api.post.get_message_for_notification.images_sent", len(filenames), props) - } - return translateFunc("api.post.get_message_for_notification.files_sent", len(filenames), props) -} - func (a *App) GetMessageForNotification(post *model.Post, translateFunc i18n.TranslateFunc) string { - return a.Srv().GetMessageForNotification(post, translateFunc) + return a.Srv().EmailService.GetMessageForNotification(post, translateFunc) } diff --git a/app/notification_email_test.go b/app/notification_email_test.go index 61f0f9faa1..43aaa52f7a 100644 --- a/app/notification_email_test.go +++ b/app/notification_email_test.go @@ -256,10 +256,6 @@ func TestGetNotificationEmailBodyFullNotificationLocaleTimeNoTimezone(t *testing zone, _ := tm.Zone() formattedTime := formattedPostTime{ - Time: tm, - Year: fmt.Sprintf("%d", tm.Year()), - Month: translateFunc(tm.Month().String()), - Day: fmt.Sprintf("%d", tm.Day()), Hour: fmt.Sprintf("%02d", tm.Hour()), Minute: fmt.Sprintf("%02d", tm.Minute()), TimeZone: zone, diff --git a/app/plugin_api.go b/app/plugin_api.go index 757d5f1725..eecee38575 100644 --- a/app/plugin_api.go +++ b/app/plugin_api.go @@ -780,7 +780,7 @@ func (api *PluginAPI) SendMail(to, subject, htmlBody string) *model.AppError { return model.NewAppError("SendMail", "plugin_api.send_mail.missing_htmlbody", nil, "", http.StatusBadRequest) } - if err := api.app.Srv().EmailService.sendNotificationMail(to, subject, htmlBody); err != nil { + if err := api.app.Srv().EmailService.SendNotificationMail(to, subject, htmlBody); err != nil { return model.NewAppError("SendMail", "plugin_api.send_mail.missing_htmlbody", nil, err.Error(), http.StatusInternalServerError) } diff --git a/app/server.go b/app/server.go index 4669285302..5c944aa4d6 100644 --- a/app/server.go +++ b/app/server.go @@ -36,6 +36,7 @@ import ( "github.com/rs/cors" "golang.org/x/crypto/acme/autocert" + "github.com/mattermost/mattermost-server/v5/app/email" "github.com/mattermost/mattermost-server/v5/app/featureflag" "github.com/mattermost/mattermost-server/v5/app/imaging" "github.com/mattermost/mattermost-server/v5/app/request" @@ -113,7 +114,7 @@ type Server struct { PluginConfigListenerId string PluginsLock sync.RWMutex - EmailService *EmailService + EmailService *email.Service hubs []*Hub hashSeed maphash.Seed @@ -416,6 +417,7 @@ func NewServer(options ...Option) (*Server, error) { ConfigFn: s.Config, Metrics: s.Metrics, Cluster: s.Cluster, + LicenseFn: s.License, }) if err != nil { return nil, errors.Wrapf(err, "unable to create users service") @@ -452,7 +454,14 @@ func NewServer(options ...Option) (*Server, error) { s.telemetryService = telemetry.New(s, s.Store, s.SearchEngine, s.Log) - emailService, err := NewEmailService(s) + emailService, err := email.NewService(email.ServiceConfig{ + ConfigFn: s.Config, + LicenseFn: s.License, + GoFn: s.Go, + TemplatesContainer: s.TemplatesContainer(), + UserService: s.userService, + Store: s.GetStore(), + }) if err != nil { return nil, errors.Wrapf(err, "unable to initialize email service") } @@ -1803,9 +1812,8 @@ func (s *Server) sendLicenseUpForRenewalEmail(users map[string]*model.User, lice if name == "" { name = user.Username } - ok, err := s.EmailService.SendLicenseUpForRenewalEmail(user.Email, name, user.Locale, *s.Config().ServiceSettings.SiteURL, renewalLink, daysToExpiration) - if !ok || err != nil { - mlog.Error("Error sending license up for renewal email to", mlog.String("user_email", user.Email)) + if err := s.EmailService.SendLicenseUpForRenewalEmail(user.Email, name, user.Locale, *s.Config().ServiceSettings.SiteURL, renewalLink, daysToExpiration); err != nil { + mlog.Error("Error sending license up for renewal email to", mlog.String("user_email", user.Email), mlog.Err(err)) countNotOks++ } } @@ -1864,7 +1872,7 @@ func (s *Server) doLicenseExpirationCheck() { mlog.Debug("Sending license expired email.", mlog.String("user_email", user.Email)) s.Go(func() { - if err := s.EmailService.SendRemoveExpiredLicenseEmail(user.Email, user.Locale, *s.Config().ServiceSettings.SiteURL); err != nil { + if err := s.SendRemoveExpiredLicenseEmail(user.Email, user.Locale, *s.Config().ServiceSettings.SiteURL); err != nil { mlog.Error("Error while sending the license expired email.", mlog.String("user_email", user.Email), mlog.Err(err)) } }) @@ -1874,6 +1882,21 @@ func (s *Server) doLicenseExpirationCheck() { s.RemoveLicense() } +// SendRemoveExpiredLicenseEmail formats an email and uses the email service to send the email to user with link pointing to CWS +// to renew the user license +func (s *Server) SendRemoveExpiredLicenseEmail(email string, locale, siteURL string) *model.AppError { + renewalLink, err := s.GenerateLicenseRenewalLink() + if err != nil { + return err + } + + if err := s.EmailService.SendRemoveExpiredLicenseEmail(renewalLink, email, locale, siteURL); err != nil { + return model.NewAppError("SendRemoveExpiredLicenseEmail", "api.license.remove_expired_license.failed.error", nil, err.Error(), http.StatusInternalServerError) + } + + return nil +} + func (s *Server) StartSearchEngine() (string, string) { if s.SearchEngine.ElasticsearchEngine != nil && s.SearchEngine.ElasticsearchEngine.IsActive() { s.Go(func() { diff --git a/app/session.go b/app/session.go index 95d25dde85..0983f792ee 100644 --- a/app/session.go +++ b/app/session.go @@ -362,7 +362,7 @@ func (a *App) CreateUserAccessToken(token *model.UserAccessToken) (*model.UserAc // Don't send emails to bot users. if !user.IsBot { - if err := a.Srv().EmailService.sendUserAccessTokenAddedEmail(user.Email, user.Locale, a.GetSiteURL()); err != nil { + if err := a.Srv().EmailService.SendUserAccessTokenAddedEmail(user.Email, user.Locale, a.GetSiteURL()); err != nil { a.Log().Error("Unable to send user access token added email", mlog.Err(err), mlog.String("user_id", user.Id)) } } diff --git a/app/team.go b/app/team.go index f947e8fc27..5a8b64b548 100644 --- a/app/team.go +++ b/app/team.go @@ -15,6 +15,7 @@ import ( "net/url" "strings" + "github.com/mattermost/mattermost-server/v5/app/email" "github.com/mattermost/mattermost-server/v5/app/imaging" "github.com/mattermost/mattermost-server/v5/app/request" "github.com/mattermost/mattermost-server/v5/model" @@ -1434,9 +1435,16 @@ func (a *App) InviteNewUsersToTeamGracefully(emailList []string, teamID, senderI if len(goodEmails) > 0 { nameFormat := *a.Config().TeamSettings.TeammateNameDisplay - err = a.Srv().EmailService.SendInviteEmails(team, user.GetDisplayName(nameFormat), user.Id, goodEmails, a.GetSiteURL()) - if err != nil { - return nil, err + eErr := a.Srv().EmailService.SendInviteEmails(team, user.GetDisplayName(nameFormat), user.Id, goodEmails, a.GetSiteURL()) + if eErr != nil { + switch { + 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) + 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) + 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) + } } } @@ -1536,9 +1544,16 @@ func (a *App) InviteGuestsToChannelsGracefully(teamID string, guestsInvite *mode 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)) } - err = a.Srv().EmailService.sendGuestInviteEmails(team, channels, user.GetDisplayName(nameFormat), user.Id, senderProfileImage, goodEmails, a.GetSiteURL(), guestsInvite.Message) - if err != nil { - return nil, err + eErr := a.Srv().EmailService.SendGuestInviteEmails(team, channels, user.GetDisplayName(nameFormat), user.Id, senderProfileImage, goodEmails, a.GetSiteURL(), guestsInvite.Message) + if eErr != nil { + switch { + 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) + 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) + 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) + } } } @@ -1575,9 +1590,16 @@ func (a *App) InviteNewUsersToTeam(emailList []string, teamID, senderId string) } nameFormat := *a.Config().TeamSettings.TeammateNameDisplay - err = a.Srv().EmailService.SendInviteEmails(team, user.GetDisplayName(nameFormat), user.Id, emailList, a.GetSiteURL()) - if err != nil { - return err + eErr := a.Srv().EmailService.SendInviteEmails(team, user.GetDisplayName(nameFormat), user.Id, emailList, a.GetSiteURL()) + if eErr != nil { + switch { + case errors.Is(eErr, email.NoRateLimiterError): + return 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) + case errors.Is(eErr, email.SetupRateLimiterError): + return 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) + default: + return 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 @@ -1610,9 +1632,16 @@ func (a *App) InviteGuestsToChannels(teamID string, guestsInvite *model.GuestsIn 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)) } - err = a.Srv().EmailService.sendGuestInviteEmails(team, channels, user.GetDisplayName(nameFormat), user.Id, senderProfileImage, guestsInvite.Emails, a.GetSiteURL(), guestsInvite.Message) - if err != nil { - return err + eErr := a.Srv().EmailService.SendGuestInviteEmails(team, channels, user.GetDisplayName(nameFormat), user.Id, senderProfileImage, guestsInvite.Emails, a.GetSiteURL(), guestsInvite.Message) + if eErr != nil { + switch { + case errors.Is(eErr, email.NoRateLimiterError): + return 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) + case errors.Is(eErr, email.SetupRateLimiterError): + return 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, err), http.StatusInternalServerError) + default: + return 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, err), http.StatusRequestEntityTooLarge) + } } return nil diff --git a/app/user.go b/app/user.go index 2fbc427f17..66c3966742 100644 --- a/app/user.go +++ b/app/user.go @@ -15,6 +15,7 @@ import ( "strconv" "strings" + "github.com/mattermost/mattermost-server/v5/app/email" "github.com/mattermost/mattermost-server/v5/app/imaging" "github.com/mattermost/mattermost-server/v5/app/request" "github.com/mattermost/mattermost-server/v5/einterfaces" @@ -143,7 +144,7 @@ func (a *App) CreateUserWithInviteId(c *request.Context, user *model.User, invit a.AddDirectChannels(team.Id, ruser) - if err := a.Srv().EmailService.sendWelcomeEmail(ruser.Id, ruser.Email, ruser.EmailVerified, ruser.DisableWelcomeEmail, ruser.Locale, a.GetSiteURL(), redirect); err != nil { + if err := a.Srv().EmailService.SendWelcomeEmail(ruser.Id, ruser.Email, ruser.EmailVerified, ruser.DisableWelcomeEmail, ruser.Locale, a.GetSiteURL(), redirect); err != nil { mlog.Warn("Failed to send welcome email on create user with inviteId", mlog.Err(err)) } @@ -156,7 +157,7 @@ func (a *App) CreateUserAsAdmin(c *request.Context, user *model.User, redirect s return nil, err } - if err := a.Srv().EmailService.sendWelcomeEmail(ruser.Id, ruser.Email, ruser.EmailVerified, ruser.DisableWelcomeEmail, ruser.Locale, a.GetSiteURL(), redirect); err != nil { + if err := a.Srv().EmailService.SendWelcomeEmail(ruser.Id, ruser.Email, ruser.EmailVerified, ruser.DisableWelcomeEmail, ruser.Locale, a.GetSiteURL(), redirect); err != nil { mlog.Warn("Failed to send welcome email to the new user, created by system admin", mlog.Err(err)) } @@ -180,7 +181,7 @@ func (a *App) CreateUserFromSignup(c *request.Context, user *model.User, redirec return nil, err } - if err := a.Srv().EmailService.sendWelcomeEmail(ruser.Id, ruser.Email, ruser.EmailVerified, ruser.DisableWelcomeEmail, ruser.Locale, a.GetSiteURL(), redirect); err != nil { + if err := a.Srv().EmailService.SendWelcomeEmail(ruser.Id, ruser.Email, ruser.EmailVerified, ruser.DisableWelcomeEmail, ruser.Locale, a.GetSiteURL(), redirect); err != nil { mlog.Warn("Failed to send welcome email on create user from signup", mlog.Err(err)) } @@ -1101,7 +1102,7 @@ func (a *App) UpdateUser(user *model.User, sendNotifications bool) (*model.User, }) } else { a.Srv().Go(func() { - if err := a.Srv().EmailService.sendEmailChangeEmail(userUpdate.Old.Email, userUpdate.New.Email, userUpdate.New.Locale, a.GetSiteURL()); err != nil { + if err := a.Srv().EmailService.SendEmailChangeEmail(userUpdate.Old.Email, userUpdate.New.Email, userUpdate.New.Locale, a.GetSiteURL()); err != nil { mlog.Error("Failed to send email change email", mlog.Err(err)) } }) @@ -1110,7 +1111,7 @@ func (a *App) UpdateUser(user *model.User, sendNotifications bool) (*model.User, if userUpdate.New.Username != userUpdate.Old.Username { a.Srv().Go(func() { - if err := a.Srv().EmailService.sendChangeUsernameEmail(userUpdate.New.Username, userUpdate.New.Email, userUpdate.New.Locale, a.GetSiteURL()); err != nil { + if err := a.Srv().EmailService.SendChangeUsernameEmail(userUpdate.New.Username, userUpdate.New.Email, userUpdate.New.Locale, a.GetSiteURL()); err != nil { mlog.Error("Failed to send change username email", mlog.Err(err)) } }) @@ -1171,7 +1172,7 @@ func (a *App) UpdateMfa(activate bool, userID, token string) *model.AppError { return } - if err := a.Srv().EmailService.sendMfaChangeEmail(user.Email, activate, user.Locale, a.GetSiteURL()); err != nil { + if err := a.Srv().EmailService.SendMfaChangeEmail(user.Email, activate, user.Locale, a.GetSiteURL()); err != nil { mlog.Error("Failed to send mfa change email", mlog.Err(err)) } }) @@ -1210,7 +1211,7 @@ func (a *App) UpdatePasswordSendEmail(user *model.User, newPassword, method stri } a.Srv().Go(func() { - if err := a.Srv().EmailService.sendPasswordChangeEmail(user.Email, method, user.Locale, a.GetSiteURL()); err != nil { + if err := a.Srv().EmailService.SendPasswordChangeEmail(user.Email, method, user.Locale, a.GetSiteURL()); err != nil { mlog.Error("Failed to send password change email", mlog.Err(err)) } }) @@ -1297,7 +1298,12 @@ func (a *App) SendPasswordReset(email string, siteURL string) (bool, *model.AppE return false, err } - return a.Srv().EmailService.SendPasswordResetEmail(user.Email, token, user.Locale, siteURL) + result, eErr := a.Srv().EmailService.SendPasswordResetEmail(user.Email, token, user.Locale, siteURL) + if eErr != nil { + return result, model.NewAppError("SendPasswordReset", "api.user.send_password_reset.send.app_error", nil, "err="+eErr.Error(), http.StatusInternalServerError) + } + + return result, nil } func (a *App) CreatePasswordRecoveryToken(userID, email string) (*model.Token, *model.AppError) { @@ -1540,13 +1546,27 @@ func (a *App) PermanentDeleteAllUsers(c *request.Context) *model.AppError { func (a *App) SendEmailVerification(user *model.User, newEmail, redirect string) *model.AppError { token, err := a.Srv().EmailService.CreateVerifyEmailToken(user.Id, newEmail) if err != nil { - return err + switch { + case errors.Is(err, email.CreateEmailTokenError): + return model.NewAppError("CreateVerifyEmailToken", "api.user.create_email_token.error", nil, "", http.StatusInternalServerError) + default: + return model.NewAppError("CreateVerifyEmailToken", "app.recover.save.app_error", nil, err.Error(), http.StatusInternalServerError) + } } if _, err := a.GetStatus(user.Id); err != nil { - return a.Srv().EmailService.sendVerifyEmail(newEmail, user.Locale, a.GetSiteURL(), token.Token, redirect) + eErr := a.Srv().EmailService.SendVerifyEmail(newEmail, user.Locale, a.GetSiteURL(), token.Token, redirect) + if eErr != nil { + return model.NewAppError("SendVerifyEmail", "api.user.send_verify_email_and_forget.failed.error", nil, eErr.Error(), http.StatusInternalServerError) + } + return nil } - return a.Srv().EmailService.sendEmailChangeVerifyEmail(newEmail, user.Locale, a.GetSiteURL(), token.Token) + + if err := a.Srv().EmailService.SendEmailChangeVerifyEmail(newEmail, user.Locale, a.GetSiteURL(), token.Token); err != nil { + return model.NewAppError("sendEmailChangeVerifyEmail", "api.user.send_email_change_verify_email_and_forget.error", nil, err.Error(), http.StatusInternalServerError) + } + + return nil } func (a *App) VerifyEmailFromToken(userSuppliedTokenString string) *model.AppError { @@ -1580,7 +1600,7 @@ func (a *App) VerifyEmailFromToken(userSuppliedTokenString string) *model.AppErr if user.Email != tokenData.Email { a.Srv().Go(func() { - if err := a.Srv().EmailService.sendEmailChangeEmail(user.Email, tokenData.Email, user.Locale, a.GetSiteURL()); err != nil { + if err := a.Srv().EmailService.SendEmailChangeEmail(user.Email, tokenData.Email, user.Locale, a.GetSiteURL()); err != nil { mlog.Error("Failed to send email change email", mlog.Err(err)) } }) @@ -1989,7 +2009,7 @@ func (a *App) GetViewUsersRestrictions(userID string) (*model.ViewUsersRestricti // PromoteGuestToUser Convert user's roles and all his mermbership's roles from // guest roles to regular user roles. func (a *App) PromoteGuestToUser(c *request.Context, user *model.User, requestorId string) *model.AppError { - nErr := a.Srv().Store.User().PromoteGuestToUser(user.Id) + nErr := a.srv.userService.PromoteGuestToUser(user) a.InvalidateCacheForUser(user.Id) if nErr != nil { return model.NewAppError("PromoteGuestToUser", "app.user.promote_guest.user_update.app_error", nil, nErr.Error(), http.StatusInternalServerError) @@ -2045,7 +2065,7 @@ func (a *App) PromoteGuestToUser(c *request.Context, user *model.User, requestor // DemoteUserToGuest Convert user's roles and all his mermbership's roles from // regular user roles to guest roles. func (a *App) DemoteUserToGuest(user *model.User) *model.AppError { - demotedUser, nErr := a.Srv().Store.User().DemoteUserToGuest(user.Id) + demotedUser, nErr := a.srv.userService.DemoteUserToGuest(user) a.InvalidateCacheForUser(user.Id) if nErr != nil { return model.NewAppError("DemoteUserToGuest", "app.user.demote_user_to_guest.user_update.app_error", nil, nErr.Error(), http.StatusInternalServerError) diff --git a/app/user_test.go b/app/user_test.go index cbf087583e..5367c72d63 100644 --- a/app/user_test.go +++ b/app/user_test.go @@ -398,19 +398,19 @@ func TestUpdateUserEmail(t *testing.T) { newEmail := th.MakeEmail() user.Email = newEmail - user2, err := th.App.UpdateUser(user, false) - assert.Nil(t, err) + user2, appErr := th.App.UpdateUser(user, false) + assert.Nil(t, appErr) assert.Equal(t, currentEmail, user2.Email) assert.True(t, user2.EmailVerified) token, err := th.App.Srv().EmailService.CreateVerifyEmailToken(user2.Id, newEmail) - assert.Nil(t, err) + assert.NoError(t, err) - err = th.App.VerifyEmailFromToken(token.Token) - assert.Nil(t, err) + appErr = th.App.VerifyEmailFromToken(token.Token) + assert.Nil(t, appErr) - user2, err = th.App.GetUser(user2.Id) - assert.Nil(t, err) + user2, appErr = th.App.GetUser(user2.Id) + assert.Nil(t, appErr) assert.Equal(t, newEmail, user2.Email) assert.True(t, user2.EmailVerified) @@ -425,8 +425,8 @@ func TestUpdateUserEmail(t *testing.T) { newBotEmail := th.MakeEmail() botuser.Email = newBotEmail - botuser2, err := th.App.UpdateUser(&botuser, false) - assert.Nil(t, err) + botuser2, appErr := th.App.UpdateUser(&botuser, false) + assert.Nil(t, appErr) assert.Equal(t, botuser2.Email, newBotEmail) }) diff --git a/app/web_hub_test.go b/app/web_hub_test.go index 469bbf657e..a5ec5e8d7a 100644 --- a/app/web_hub_test.go +++ b/app/web_hub_test.go @@ -168,6 +168,7 @@ func TestHubSessionRevokeRace(t *testing.T) { ConfigFn: th.App.srv.Config, Metrics: th.App.Metrics(), Cluster: th.App.Cluster(), + LicenseFn: th.App.srv.License, }) require.NoError(t, err) th.App.srv.userService = userService diff --git a/go.tools.mod b/go.tools.mod index f3192f8bd6..4f273f5ed0 100644 --- a/go.tools.mod +++ b/go.tools.mod @@ -7,7 +7,7 @@ require ( github.com/golang-migrate/migrate/v4 v4.14.1 // indirect github.com/jstemmer/go-junit-report v0.9.1 // indirect github.com/jteeuwen/go-bindata v3.0.7+incompatible // indirect - github.com/mattermost/mattermost-utilities/mmgotool v0.0.0-20210309083648-c1e5575135f9 // indirect + github.com/mattermost/mattermost-utilities/mmgotool v0.0.0-20210714114450-fbc82c4cf833 // indirect github.com/philhofer/fwd v1.0.0 // indirect github.com/reflog/struct2interface v0.6.1 // indirect github.com/spf13/cobra v1.1.3 // indirect diff --git a/go.tools.sum b/go.tools.sum index e01ab91227..0315b51c12 100644 --- a/go.tools.sum +++ b/go.tools.sum @@ -344,6 +344,8 @@ github.com/mattermost/mattermost-utilities/mmgotool v0.0.0-20210218104610-40d764 github.com/mattermost/mattermost-utilities/mmgotool v0.0.0-20210218104610-40d7640e8538/go.mod h1:3gKozJI8n2Y/vW37GfnFWAdehGXe5yZlt+HykK6Y3DM= github.com/mattermost/mattermost-utilities/mmgotool v0.0.0-20210309083648-c1e5575135f9 h1:EdA8k1LBxdk1SslBITXYiGVIptfPWFt7fRwxiy2BsTk= github.com/mattermost/mattermost-utilities/mmgotool v0.0.0-20210309083648-c1e5575135f9/go.mod h1:3gKozJI8n2Y/vW37GfnFWAdehGXe5yZlt+HykK6Y3DM= +github.com/mattermost/mattermost-utilities/mmgotool v0.0.0-20210714114450-fbc82c4cf833 h1:Cgx5Md/4umqKYAgu8oPTZ+vDPZ5DaaRpjUjR+CUsmNI= +github.com/mattermost/mattermost-utilities/mmgotool v0.0.0-20210714114450-fbc82c4cf833/go.mod h1:3gKozJI8n2Y/vW37GfnFWAdehGXe5yZlt+HykK6Y3DM= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.1 h1:G1f5SKeVxmagw/IyvzvtZE4Gybcc4Tr1tf7I8z0XgOg= github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= diff --git a/i18n/en.json b/i18n/en.json index a65a171728..4d88423095 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -3866,14 +3866,6 @@ "id": "api.user.check_user_password.invalid.app_error", "translation": "Login failed because of invalid password." }, - { - "id": "api.user.cloud_trial_ended_email.error", - "translation": "Failed to send trial ended email" - }, - { - "id": "api.user.cloud_trial_ending_email.error", - "translation": "Failed to send trial ending warning email" - }, { "id": "api.user.complete_switch_with_oauth.blank_email.app_error", "translation": "Blank email." @@ -4130,30 +4122,10 @@ "id": "api.user.send_deactivate_email_and_forget.failed.error", "translation": "Failed to send the deactivate account email successfully" }, - { - "id": "api.user.send_email_change_email_and_forget.error", - "translation": "Failed to send email change notification email successfully" - }, - { - "id": "api.user.send_email_change_username_and_forget.error", - "translation": "Failed to send username change notification email successfully" - }, { "id": "api.user.send_email_change_verify_email_and_forget.error", "translation": "Failed to send email change verification email successfully" }, - { - "id": "api.user.send_license_up_for_renewal_email.error", - "translation": "Failed to send license up for renewal email" - }, - { - "id": "api.user.send_mfa_change_email.error", - "translation": "Unable to send email notification for MFA change." - }, - { - "id": "api.user.send_password_change_email_and_forget.error", - "translation": "Failed to send update password email successfully" - }, { "id": "api.user.send_password_reset.send.app_error", "translation": "Failed to send password reset email successfully." @@ -4166,22 +4138,10 @@ "id": "api.user.send_sign_in_change_email_and_forget.error", "translation": "Failed to send update password email successfully" }, - { - "id": "api.user.send_upgrade_request_email.error", - "translation": "Failed to send email to user limit notification to admin" - }, - { - "id": "api.user.send_user_access_token.error", - "translation": "Failed to send \"Personal access token added\" email successfully" - }, { "id": "api.user.send_verify_email_and_forget.failed.error", "translation": "Failed to send verification email successfully" }, - { - "id": "api.user.send_welcome_email_and_forget.failed.error", - "translation": "Failed to send welcome email successfully" - }, { "id": "api.user.update_active.cannot_enable_guest_when_guest_feature_is_disabled.app_error", "translation": "You cannot activate a guest account because Guest Access feature is not enabled." diff --git a/services/users/profile_picture.go b/services/users/profile_picture.go index 11b5486981..c07eb9b0ef 100644 --- a/services/users/profile_picture.go +++ b/services/users/profile_picture.go @@ -10,13 +10,16 @@ import ( "image/color" "image/draw" "image/png" + "io" "io/ioutil" + "path" "path/filepath" "strings" "github.com/golang/freetype" "github.com/golang/freetype/truetype" "github.com/mattermost/mattermost-server/v5/model" + "github.com/mattermost/mattermost-server/v5/shared/filestore" "github.com/mattermost/mattermost-server/v5/utils/fileutils" ) @@ -24,6 +27,68 @@ const ( imageProfilePixelDimension = 128 ) +func (us *UserService) GetProfileImage(user *model.User) ([]byte, bool, error) { + if *us.config().FileSettings.DriverName == "" { + img, err := us.GetDefaultProfileImage(user) + if err != nil { + return nil, false, err + } + return img, false, nil + } + + path := path.Join("users", user.Id, "profile.png") + data, err := us.ReadFile(path) + if err != nil { + img, appErr := us.GetDefaultProfileImage(user) + if appErr != nil { + return nil, false, appErr + } + + if user.LastPictureUpdate == 0 { + if _, err := us.writeFile(bytes.NewReader(img), path); err != nil { + return nil, false, err + } + } + return img, true, nil + } + + return data, false, nil +} + +func (us *UserService) FileBackend() (filestore.FileBackend, error) { + license := us.license() + backend, err := filestore.NewFileBackend(us.config().FileSettings.ToFileBackendSettings(license != nil && *license.Features.Compliance)) + if err != nil { + return nil, err + } + return backend, nil +} + +func (us *UserService) ReadFile(path string) ([]byte, error) { + backend, err := us.FileBackend() + if err != nil { + return nil, err + } + result, nErr := backend.ReadFile(path) + if nErr != nil { + return nil, nErr + } + return result, nil +} + +func (us *UserService) writeFile(fr io.Reader, path string) (int64, error) { + backend, err := us.FileBackend() + if err != nil { + return 0, err + } + + result, nErr := backend.WriteFile(fr, path) + if nErr != nil { + return result, nErr + } + return result, nil +} + func (us *UserService) GetDefaultProfileImage(user *model.User) ([]byte, error) { if user.IsBot { return botDefaultImage, nil diff --git a/services/users/service.go b/services/users/service.go index 41f4c82550..b3dd2d419b 100644 --- a/services/users/service.go +++ b/services/users/service.go @@ -24,6 +24,7 @@ type UserService struct { metrics einterfaces.MetricsInterface cluster einterfaces.ClusterInterface config func() *model.Config + license func() *model.License } // ServiceConfig is used to initialize the UserService. @@ -33,6 +34,7 @@ type ServiceConfig struct { SessionStore store.SessionStore OAuthStore store.OAuthStore ConfigFn func() *model.Config + LicenseFn func() *model.License // Optional fields Metrics einterfaces.MetricsInterface Cluster einterfaces.ClusterInterface @@ -62,6 +64,7 @@ func New(c ServiceConfig) (*UserService, error) { sessionStore: c.SessionStore, oAuthStore: c.OAuthStore, config: c.ConfigFn, + license: c.LicenseFn, metrics: c.Metrics, cluster: c.Cluster, sessionCache: sessionCache, @@ -74,7 +77,7 @@ func New(c ServiceConfig) (*UserService, error) { } func (c *ServiceConfig) validate() error { - if in := c; in.ConfigFn == nil || in.UserStore == nil || in.SessionStore == nil || in.OAuthStore == nil { + if c.ConfigFn == nil || c.UserStore == nil || c.SessionStore == nil || c.OAuthStore == nil || c.LicenseFn == nil { return errors.New("required parameters are not provided") } diff --git a/services/users/service_test.go b/services/users/service_test.go index af04f3bc65..20a9f5bc15 100644 --- a/services/users/service_test.go +++ b/services/users/service_test.go @@ -20,11 +20,16 @@ func TestNew(t *testing.T) { return &model.Config{} } + lfn := func() *model.License { + return model.NewTestLicense() + } + _, err = New(ServiceConfig{ UserStore: dbStore.User(), SessionStore: dbStore.Session(), OAuthStore: dbStore.OAuth(), ConfigFn: cfn, + LicenseFn: lfn, }) require.NoError(t, err) } diff --git a/services/users/users.go b/services/users/users.go index 292f990574..ee9cc19c7c 100644 --- a/services/users/users.go +++ b/services/users/users.go @@ -242,3 +242,11 @@ func (us *UserService) ActivateMfa(user *model.User, token string) error { func (us *UserService) DeactivateMfa(user *model.User) error { return mfa.New(us.store).Deactivate(user.Id) } + +func (us *UserService) PromoteGuestToUser(user *model.User) error { + return us.store.PromoteGuestToUser(user.Id) +} + +func (us *UserService) DemoteUserToGuest(user *model.User) (*model.User, error) { + return us.store.DemoteUserToGuest(user.Id) +} diff --git a/web/saml.go b/web/saml.go index d7a1fe9161..dc0700f35c 100644 --- a/web/saml.go +++ b/web/saml.go @@ -154,8 +154,8 @@ func completeSaml(c *Context, w http.ResponseWriter, r *http.Request) { c.LogAuditWithUserId(user.Id, "Revoked all sessions for user") c.App.Srv().Go(func() { - if err = c.App.Srv().EmailService.SendSignInChangeEmail(user.Email, strings.Title(model.USER_AUTH_SERVICE_SAML)+" SSO", user.Locale, c.App.GetSiteURL()); err != nil { - c.LogErrorByCode(err) + if err := c.App.Srv().EmailService.SendSignInChangeEmail(user.Email, strings.Title(model.USER_AUTH_SERVICE_SAML)+" SSO", user.Locale, c.App.GetSiteURL()); err != nil { + c.LogErrorByCode(model.NewAppError("SendSignInChangeEmail", "api.user.send_sign_in_change_email_and_forget.error", nil, err.Error(), http.StatusInternalServerError)) } }) }