diff --git a/api4/cloud.go b/api4/cloud.go index 009a990f00..e6351d0ac9 100644 --- a/api4/cloud.go +++ b/api4/cloud.go @@ -35,6 +35,7 @@ func (api *API) InitCloud() { api.BaseRoutes.Cloud.Handle("/subscription", api.ApiSessionRequired(getSubscription)).Methods("GET") api.BaseRoutes.Cloud.Handle("/subscription/invoices", api.ApiSessionRequired(getInvoicesForSubscription)).Methods("GET") api.BaseRoutes.Cloud.Handle("/subscription/invoices/{invoice_id:in_[A-Za-z0-9]+}/pdf", api.ApiSessionRequired(getSubscriptionInvoicePDF)).Methods("GET") + api.BaseRoutes.Cloud.Handle("/subscription/limitreached/invite", api.ApiSessionRequired(sendAdminUpgradeRequestEmail)).Methods("POST") api.BaseRoutes.Cloud.Handle("/subscription/stats", api.ApiHandler(getSubscriptionStats)).Methods("GET") // POST /api/v4/cloud/webhook @@ -389,3 +390,29 @@ func handleCWSWebhook(c *Context, w http.ResponseWriter, r *http.Request) { ReturnStatusOK(w) } + +func sendAdminUpgradeRequestEmail(c *Context, w http.ResponseWriter, r *http.Request) { + if c.App.Srv().License() == nil || !*c.App.Srv().License().Features.Cloud { + c.Err = model.NewAppError("Api4.sendAdminUpgradeRequestEmail", "api.cloud.license_error", nil, "", http.StatusNotImplemented) + return + } + + user, err := c.App.GetUser(c.App.Session().UserId) + if err != nil { + c.Err = model.NewAppError("Api4.sendAdminUpgradeRequestEmail", err.Id, nil, err.Error(), err.StatusCode) + return + } + + sub, err := c.App.Cloud().GetSubscription() + if err != nil { + c.Err = model.NewAppError("Api4.sendAdminUpgradeRequestEmail", "api.cloud.request_error", nil, err.Error(), http.StatusInternalServerError) + return + } + + if err = c.App.SendAdminUpgradeRequestEmail(user.Username, sub); err != nil { + c.Err = model.NewAppError("Api4.sendAdminUpgradeRequestEmail", err.Id, nil, err.Error(), err.StatusCode) + return + } + + ReturnStatusOK(w) +} diff --git a/app/app_iface.go b/app/app_iface.go index 4f23850f6c..7e3fbe97f7 100644 --- a/app/app_iface.go +++ b/app/app_iface.go @@ -265,6 +265,9 @@ type AppIface interface { SearchAllChannels(term string, opts model.ChannelSearchOpts) (*model.ChannelListWithTeamData, int64, *model.AppError) // SearchAllTeams returns a team list and the total count of the results SearchAllTeams(searchOpts *model.TeamSearch) ([]*model.Team, int64, *model.AppError) + // SendAdminUpgradeRequestEmail takes the username of user trying to alert admins and then applies rate limit of n (number of admins) emails per user per day + // before sending the emails. + SendAdminUpgradeRequestEmail(username string, subscription *model.Subscription) *model.AppError // SendNoCardPaymentFailedEmail SendNoCardPaymentFailedEmail() *model.AppError // ServePluginPublicRequest serves public plugin files diff --git a/app/cloud.go b/app/cloud.go index bf79a52e8f..514a0684e1 100644 --- a/app/cloud.go +++ b/app/cloud.go @@ -4,6 +4,9 @@ package app import ( + "fmt" + "net/http" + "github.com/mattermost/mattermost-server/v5/mlog" "github.com/mattermost/mattermost-server/v5/model" ) @@ -18,6 +21,59 @@ func (a *App) getSysAdminsEmailRecipients() ([]*model.User, *model.AppError) { return a.GetUsers(userOptions) } +// SendAdminUpgradeRequestEmail takes the username of user trying to alert admins and then applies rate limit of n (number of admins) emails per user per day +// before sending the emails. +func (a *App) SendAdminUpgradeRequestEmail(username string, subscription *model.Subscription) *model.AppError { + if a.Srv().License() == nil || (a.Srv().License() != nil && !*a.Srv().License().Features.Cloud) { + return nil + } + + if subscription != nil && subscription.IsPaidTier == "true" { + return nil + } + + if a.Srv().EmailService.PerDayEmailRateLimiter == nil { + return model.NewAppError("app.SendAdminUpgradeRequestEmail", "app.email.no_rate_limiter.app_error", nil, fmt.Sprintf("for username=%s", username), http.StatusInternalServerError) + } + + // rate limit based on username as key + rateLimited, result, err := a.Srv().EmailService.PerDayEmailRateLimiter.RateLimit(username, 1) + if err != nil { + return model.NewAppError("app.SendAdminUpgradeRequestEmail", "app.email.setup_rate_limiter.app_error", nil, fmt.Sprintf("username=%s, error=%v", username, err), http.StatusInternalServerError) + } + + if rateLimited { + return model.NewAppError("app.SendAdminUpgradeRequestEmail", + "app.email.rate_limit_exceeded.app_error", map[string]interface{}{"RetryAfter": result.RetryAfter.String(), "ResetAfter": result.ResetAfter.String()}, + fmt.Sprintf("username=%s, retry_after_secs=%f, reset_after_secs=%f", + username, result.RetryAfter.Seconds(), result.ResetAfter.Seconds()), + http.StatusRequestEntityTooLarge) + } + + sysAdmins, e := a.getSysAdminsEmailRecipients() + if e != nil { + return e + } + + // we want to at least have one email sent out to an admin + countNotOks := 0 + + for admin := range sysAdmins { + ok, err := a.Srv().EmailService.SendUpgradeEmail(username, sysAdmins[admin].Email, sysAdmins[admin].Locale, *a.Config().ServiceSettings.SiteURL) + if !ok || err != nil { + a.Log().Error("Error sending upgrade request email", mlog.Err(err)) + countNotOks++ + } + } + + // if not even one admin got an email, we consider that this operation errored + if countNotOks == len(sysAdmins) { + return model.NewAppError("app.SendAdminUpgradeRequestEmail", "app.user.send_emails.app_error", nil, "", http.StatusInternalServerError) + } + + return nil +} + func (a *App) CheckAndSendUserLimitWarningEmails() *model.AppError { if a.Srv().License() == nil || (a.Srv().License() != nil && !*a.Srv().License().Features.Cloud) { // Not cloud instance, do nothing diff --git a/app/email.go b/app/email.go index b6984c2fd8..52f8aa6e6a 100644 --- a/app/email.go +++ b/app/email.go @@ -41,37 +41,49 @@ func condenseSiteURL(siteURL string) string { } type EmailService struct { - srv *Server - EmailRateLimiter *throttled.GCRARateLimiter - EmailBatching *EmailBatchingJob + srv *Server + PerHourEmailRateLimiter *throttled.GCRARateLimiter + PerDayEmailRateLimiter *throttled.GCRARateLimiter + EmailBatching *EmailBatchingJob } func NewEmailService(srv *Server) (*EmailService, error) { service := &EmailService{srv: srv} - if err := service.setupInviteEmailRateLimiting(); err != nil { + if err := service.setUpRateLimiters(); err != nil { return nil, err } service.InitEmailBatching() return service, nil } -func (es *EmailService) setupInviteEmailRateLimiting() error { +func (es *EmailService) setUpRateLimiters() error { store, err := memstore.New(emailRateLimitingMemstoreSize) if err != nil { return errors.Wrap(err, "Unable to setup email rate limiting memstore.") } - quota := throttled.RateQuota{ + perHourQuota := throttled.RateQuota{ MaxRate: throttled.PerHour(emailRateLimitingPerHour), MaxBurst: emailRateLimitingMaxBurst, } - rateLimiter, err := throttled.NewGCRARateLimiter(store, quota) - if err != nil || rateLimiter == nil { + 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.") } - es.EmailRateLimiter = rateLimiter + 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 } @@ -324,10 +336,10 @@ func (es *EmailService) sendMfaChangeEmail(email string, activated bool, locale, } func (es *EmailService) SendInviteEmails(team *model.Team, senderName string, senderUserId string, invites []string, siteURL string) *model.AppError { - if es.EmailRateLimiter == nil { + 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) } - rateLimited, result, err := es.EmailRateLimiter.RateLimit(senderUserId, len(invites)) + 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) } @@ -383,10 +395,10 @@ func (es *EmailService) SendInviteEmails(team *model.Team, senderName string, se } func (es *EmailService) sendGuestInviteEmails(team *model.Team, channels []*model.Channel, senderName string, senderUserId string, senderProfileImage []byte, invites []string, siteURL string, message string) *model.AppError { - if es.EmailRateLimiter == nil { + 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) } - rateLimited, result, err := es.EmailRateLimiter.RateLimit(senderUserId, len(invites)) + 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) } @@ -621,6 +633,29 @@ func (es *EmailService) SendAtUserLimitWarningEmail(email string, locale string, return true, 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 string) (bool, *model.AppError) { + T := utils.GetUserTranslations(locale) + + subject := T("api.templates.upgrade_request_subject") + + bodyPage := es.newEmailTemplate("cloud_upgrade_request_email", locale) + bodyPage.Props["Title"] = T("api.templates.upgrade_request_title", map[string]interface{}{"UserName": user}) + bodyPage.Props["Info4"] = T("api.templates.upgrade_request_info4") + bodyPage.Props["Info5"] = T("api.templates.at_limit_info5") + bodyPage.Props["BillingPath"] = "admin_console/billing/subscription" + bodyPage.Props["SiteURL"] = siteURL + bodyPage.Props["Button"] = T("api.templates.upgrade_mattermost_cloud") + bodyPage.Props["EmailUs"] = T("api.templates.email_us_anytime_at") + bodyPage.Props["Footer"] = T("api.templates.copyright") + + if err := es.sendMail(email, subject, bodyPage.Render()); err != nil { + return false, model.NewAppError("SendUpgradeEmail", "api.user.send_upgrade_request_email.error", nil, err.Error(), http.StatusInternalServerError) + } + + return true, nil +} + func (es *EmailService) SendOverUserLimitWarningEmail(email string, locale string, siteURL string) (bool, *model.AppError) { T := utils.GetUserTranslations(locale) diff --git a/app/email_test.go b/app/email_test.go index 4f50c1a469..32f847c322 100644 --- a/app/email_test.go +++ b/app/email_test.go @@ -61,3 +61,38 @@ func TestSendInviteEmailRateLimits(t *testing.T) { assert.Equal(t, "app.email.rate_limit_exceeded.app_error", err.Id) assert.Equal(t, http.StatusRequestEntityTooLarge, err.StatusCode) } + +func TestSendAdminUpgradeRequestEmail(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + th.App.Srv().SetLicense(model.NewTestLicense("cloud")) + + mockSubscription := &model.Subscription{ + ID: "MySubscriptionID", + CustomerID: "MyCustomer", + ProductID: "SomeProductId", + AddOns: []string{}, + StartAt: 1000000000, + EndAt: 2000000000, + CreateAt: 1000000000, + Seats: 100, + DNS: "some.dns.server", + IsPaidTier: "false", + } + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ExperimentalSettings.CloudUserLimit = 10 + }) + + err := th.App.SendAdminUpgradeRequestEmail(th.BasicUser.Username, mockSubscription) + require.Nil(t, err) + + err = th.App.SendAdminUpgradeRequestEmail(th.BasicUser2.Username, mockSubscription) + require.Nil(t, err) + + // second attempt by the same user to send emails is blocked by rate limiter + err = th.App.SendAdminUpgradeRequestEmail(th.BasicUser.Username, mockSubscription) + require.NotNil(t, err) + assert.Equal(t, err.Id, "app.email.rate_limit_exceeded.app_error") +} diff --git a/app/opentracing/opentracing_layer.go b/app/opentracing/opentracing_layer.go index a88ccdc839..83bccf2bde 100644 --- a/app/opentracing/opentracing_layer.go +++ b/app/opentracing/opentracing_layer.go @@ -13220,6 +13220,28 @@ func (a *OpenTracingAppLayer) SendAckToPushProxy(ack *model.PushNotificationAck) return resultVar0 } +func (a *OpenTracingAppLayer) SendAdminUpgradeRequestEmail(username string, subscription *model.Subscription) *model.AppError { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SendAdminUpgradeRequestEmail") + + a.ctx = newCtx + a.app.Srv().Store.SetContext(newCtx) + defer func() { + a.app.Srv().Store.SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0 := a.app.SendAdminUpgradeRequestEmail(username, subscription) + + if resultVar0 != nil { + span.LogFields(spanlog.Error(resultVar0)) + ext.Error.Set(span, true) + } + + return resultVar0 +} + func (a *OpenTracingAppLayer) SendAutoResponse(channel *model.Channel, receiver *model.User, post *model.Post) (bool, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SendAutoResponse") diff --git a/i18n/en.json b/i18n/en.json index bc63a92e4b..2144d05f03 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -2606,6 +2606,10 @@ "id": "api.templates.at_limit_info2", "translation": "Alternatively, you can disable users in the Admin Console to open up spots for more users or stay below the free user limit." }, + { + "id": "api.templates.at_limit_info5", + "translation": "Alternatively, you can disable users in the Admin Console to open up spots for users and stay below the free user limit." + }, { "id": "api.templates.at_limit_subject", "translation": "Mattermost Cloud User Limit Reached" @@ -2962,6 +2966,18 @@ "id": "api.templates.upgrade_mattermost_cloud", "translation": "Upgrade" }, + { + "id": "api.templates.upgrade_request_info4", + "translation": "Because your workspace has reached the user limit for the free version of Mattermost cloud, invitations cannot be sent. Upgrade now to allow more users to join your workspace." + }, + { + "id": "api.templates.upgrade_request_subject", + "translation": "Mattermost user request upgrade of workspace" + }, + { + "id": "api.templates.upgrade_request_title", + "translation": "{{ .UserName }} would like to invite members to your workspace" + }, { "id": "api.templates.user_access_token_body.info", "translation": "A personal access token was added to your account on {{ .SiteURL }}. They can be used to access {{.SiteName}} with your account." @@ -3446,6 +3462,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" @@ -5730,6 +5750,10 @@ "id": "app.user.search.app_error", "translation": "Unable to find any user matching the search parameters." }, + { + "id": "app.user.send_emails.app_error", + "translation": "No emails were successfully sent" + }, { "id": "app.user.update.find.app_error", "translation": "Unable to find the existing account to update." diff --git a/model/client4.go b/model/client4.go index 808860c254..8add017dce 100644 --- a/model/client4.go +++ b/model/client4.go @@ -5972,3 +5972,13 @@ func (c *Client4) UpdateThreadFollowForUser(userId, teamId, threadId string, sta return BuildResponse(r) } + +func (c *Client4) SendAdminUpgradeRequestEmail() *Response { + r, appErr := c.DoApiPost(c.GetCloudRoute()+"/subscription/limitreached/invite", "") + if appErr != nil { + return BuildErrorResponse(r, appErr) + } + defer closeBody(r) + + return BuildResponse(r) +} diff --git a/templates/cloud_upgrade_request_email.html b/templates/cloud_upgrade_request_email.html new file mode 100644 index 0000000000..2e9bb87ab1 --- /dev/null +++ b/templates/cloud_upgrade_request_email.html @@ -0,0 +1,88 @@ +{{define "cloud_upgrade_request_email"}} + + + + +
+ + + + +
+ + + + +
+ +
+ + + + +
+ + + + +
+ + + + +
+

{{ .Props.Title }}

+
+

+ {{ .Props.Info4 }}

+

+ {{ .Props.Button }} +

+

+ {{ .Props.Info5 }}

+ +
+
+ + + + +
+ + + + +
+

Questions?

+

{{ .Props.EmailUs }} feedback@mattermost.com

+
+ + + + +
+ + + + + +
+

+ {{.Props.Organization}}
+ {{.Props.Footer}} +

+
+
+
+ +{{end}}