From 0dad2040077789bee0189a5b91a2962ed81b3da6 Mon Sep 17 00:00:00 2001 From: Allan Guwatudde Date: Wed, 24 Feb 2021 17:37:24 +0300 Subject: [PATCH] [MM-32691] - Send email when the users are not able to join a workspace (#16958) * [MM-32691] - Send email when the users are not able to join a workspace * Revert "[MM-32691] - Send email when the users are not able to join a workspace" This reverts commit 3c11643c7c6867d992743a15675f0099df4f4e4d. * Feeback impl-1 * use date as key for rate limiting * Fix tests * Translations Co-authored-by: Mattermod --- api4/cloud.go | 23 +++++++++++++- app/app_iface.go | 2 +- app/cloud.go | 20 +++++++----- app/email.go | 16 +++++++--- app/email_test.go | 47 +++++++++++++++++++++++++--- app/opentracing/opentracing_layer.go | 4 +-- i18n/en.json | 12 ++++--- model/client4.go | 10 ++++++ model/cloud.go | 2 ++ 9 files changed, 110 insertions(+), 26 deletions(-) diff --git a/api4/cloud.go b/api4/cloud.go index e6351d0ac9..a329d5f6c2 100644 --- a/api4/cloud.go +++ b/api4/cloud.go @@ -36,6 +36,7 @@ func (api *API) InitCloud() { 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/limitreached/join", api.ApiHandler(sendAdminUpgradeRequestEmailOnJoin)).Methods("POST") api.BaseRoutes.Cloud.Handle("/subscription/stats", api.ApiHandler(getSubscriptionStats)).Methods("GET") // POST /api/v4/cloud/webhook @@ -409,7 +410,27 @@ func sendAdminUpgradeRequestEmail(c *Context, w http.ResponseWriter, r *http.Req return } - if err = c.App.SendAdminUpgradeRequestEmail(user.Username, sub); err != nil { + if err = c.App.SendAdminUpgradeRequestEmail(user.Username, sub, model.InviteLimitation); err != nil { + c.Err = model.NewAppError("Api4.sendAdminUpgradeRequestEmail", err.Id, nil, err.Error(), err.StatusCode) + return + } + + ReturnStatusOK(w) +} + +func sendAdminUpgradeRequestEmailOnJoin(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.sendAdminUpgradeRequestEmailOnJoin", "api.cloud.license_error", nil, "", http.StatusNotImplemented) + return + } + + sub, err := c.App.Cloud().GetSubscription() + if err != nil { + c.Err = model.NewAppError("Api4.sendAdminUpgradeRequestEmailOnJoin", "api.cloud.request_error", nil, err.Error(), http.StatusInternalServerError) + return + } + + if err = c.App.SendAdminUpgradeRequestEmail("", sub, model.JoinLimitation); err != nil { c.Err = model.NewAppError("Api4.sendAdminUpgradeRequestEmail", err.Id, nil, err.Error(), err.StatusCode) return } diff --git a/app/app_iface.go b/app/app_iface.go index feea73eea0..81e5e058d5 100644 --- a/app/app_iface.go +++ b/app/app_iface.go @@ -267,7 +267,7 @@ type AppIface interface { 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 + SendAdminUpgradeRequestEmail(username string, subscription *model.Subscription, action string) *model.AppError // SendNoCardPaymentFailedEmail SendNoCardPaymentFailedEmail() *model.AppError // ServePluginPublicRequest serves public plugin files diff --git a/app/cloud.go b/app/cloud.go index 514a0684e1..33137deee8 100644 --- a/app/cloud.go +++ b/app/cloud.go @@ -6,6 +6,7 @@ package app import ( "fmt" "net/http" + "time" "github.com/mattermost/mattermost-server/v5/mlog" "github.com/mattermost/mattermost-server/v5/model" @@ -23,7 +24,7 @@ func (a *App) getSysAdminsEmailRecipients() ([]*model.User, *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. -func (a *App) SendAdminUpgradeRequestEmail(username string, subscription *model.Subscription) *model.AppError { +func (a *App) SendAdminUpgradeRequestEmail(username string, subscription *model.Subscription, action string) *model.AppError { if a.Srv().License() == nil || (a.Srv().License() != nil && !*a.Srv().License().Features.Cloud) { return nil } @@ -32,21 +33,24 @@ func (a *App) SendAdminUpgradeRequestEmail(username string, subscription *model. return nil } + year, month, day := time.Now().Date() + key := fmt.Sprintf("%s-%d-%s-%d", action, day, month, year) + 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) + return model.NewAppError("app.SendAdminUpgradeRequestEmail", "app.email.no_rate_limiter.app_error", nil, fmt.Sprintf("for key=%s", key), http.StatusInternalServerError) } - // rate limit based on username as key - rateLimited, result, err := a.Srv().EmailService.PerDayEmailRateLimiter.RateLimit(username, 1) + // rate limit based on combination of date and action as key + rateLimited, result, err := a.Srv().EmailService.PerDayEmailRateLimiter.RateLimit(key, 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) + return model.NewAppError("app.SendAdminUpgradeRequestEmail", "app.email.setup_rate_limiter.app_error", nil, fmt.Sprintf("for key=%s, error=%v", key, 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()), + fmt.Sprintf("key=%s, retry_after_secs=%f, reset_after_secs=%f", + key, result.RetryAfter.Seconds(), result.ResetAfter.Seconds()), http.StatusRequestEntityTooLarge) } @@ -59,7 +63,7 @@ func (a *App) SendAdminUpgradeRequestEmail(username string, subscription *model. countNotOks := 0 for admin := range sysAdmins { - ok, err := a.Srv().EmailService.SendUpgradeEmail(username, sysAdmins[admin].Email, sysAdmins[admin].Locale, *a.Config().ServiceSettings.SiteURL) + ok, err := a.Srv().EmailService.SendUpgradeEmail(username, sysAdmins[admin].Email, sysAdmins[admin].Locale, *a.Config().ServiceSettings.SiteURL, action) if !ok || err != nil { a.Log().Error("Error sending upgrade request email", mlog.Err(err)) countNotOks++ diff --git a/app/email.go b/app/email.go index b5d420da7c..a0dfd87909 100644 --- a/app/email.go +++ b/app/email.go @@ -634,14 +634,20 @@ func (es *EmailService) SendAtUserLimitWarningEmail(email string, locale string, } // 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) { +func (es *EmailService) SendUpgradeEmail(user, email, locale, siteURL, action 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") + + if action == model.InviteLimitation { + bodyPage.Props["Title"] = T("api.templates.upgrade_request_title", map[string]interface{}{"UserName": user}) + bodyPage.Props["Info4"] = T("api.templates.upgrade_request_info4") + } else { + bodyPage.Props["Title"] = T("api.templates.upgrade_request_title2") + bodyPage.Props["Info4"] = T("api.templates.upgrade_request_info4_2") + } + + subject := T("api.templates.upgrade_request_subject") bodyPage.Props["Info5"] = T("api.templates.at_limit_info5") bodyPage.Props["BillingPath"] = "admin_console/billing/subscription" bodyPage.Props["SiteURL"] = siteURL diff --git a/app/email_test.go b/app/email_test.go index 32f847c322..120c817420 100644 --- a/app/email_test.go +++ b/app/email_test.go @@ -85,14 +85,51 @@ func TestSendAdminUpgradeRequestEmail(t *testing.T) { *cfg.ExperimentalSettings.CloudUserLimit = 10 }) - err := th.App.SendAdminUpgradeRequestEmail(th.BasicUser.Username, mockSubscription) + err := th.App.SendAdminUpgradeRequestEmail(th.BasicUser.Username, mockSubscription, model.InviteLimitation) require.Nil(t, err) - err = th.App.SendAdminUpgradeRequestEmail(th.BasicUser2.Username, mockSubscription) - require.Nil(t, err) + // other attempts by the same user or other users to send emails are blocked by rate limiter + err = th.App.SendAdminUpgradeRequestEmail(th.BasicUser.Username, mockSubscription, model.InviteLimitation) + require.NotNil(t, err) + assert.Equal(t, err.Id, "app.email.rate_limit_exceeded.app_error") - // second attempt by the same user to send emails is blocked by rate limiter - err = th.App.SendAdminUpgradeRequestEmail(th.BasicUser.Username, mockSubscription) + err = th.App.SendAdminUpgradeRequestEmail(th.BasicUser2.Username, mockSubscription, model.InviteLimitation) + require.NotNil(t, err) + assert.Equal(t, err.Id, "app.email.rate_limit_exceeded.app_error") +} + +func TestSendAdminUpgradeRequestEmailOnJoin(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, model.JoinLimitation) + require.Nil(t, err) + + // other attempts by the same user or other users to send emails are blocked by rate limiter + err = th.App.SendAdminUpgradeRequestEmail(th.BasicUser.Username, mockSubscription, model.JoinLimitation) + require.NotNil(t, err) + assert.Equal(t, err.Id, "app.email.rate_limit_exceeded.app_error") + + err = th.App.SendAdminUpgradeRequestEmail(th.BasicUser2.Username, mockSubscription, model.JoinLimitation) 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 537615e8df..b6d67226c3 100644 --- a/app/opentracing/opentracing_layer.go +++ b/app/opentracing/opentracing_layer.go @@ -13264,7 +13264,7 @@ func (a *OpenTracingAppLayer) SendAckToPushProxy(ack *model.PushNotificationAck) return resultVar0 } -func (a *OpenTracingAppLayer) SendAdminUpgradeRequestEmail(username string, subscription *model.Subscription) *model.AppError { +func (a *OpenTracingAppLayer) SendAdminUpgradeRequestEmail(username string, subscription *model.Subscription, action string) *model.AppError { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SendAdminUpgradeRequestEmail") @@ -13276,7 +13276,7 @@ func (a *OpenTracingAppLayer) SendAdminUpgradeRequestEmail(username string, subs }() defer span.Finish() - resultVar0 := a.app.SendAdminUpgradeRequestEmail(username, subscription) + resultVar0 := a.app.SendAdminUpgradeRequestEmail(username, subscription, action) if resultVar0 != nil { span.LogFields(spanlog.Error(resultVar0)) diff --git a/i18n/en.json b/i18n/en.json index 90bc6506f1..cb314362ff 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -3006,6 +3006,10 @@ "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_info4_2", + "translation": "Someone recently tried to join your workspace but was unable to as your workspace has reached the user limit for the free version of Mattermost cloud. Upgrade now to allow more users to join your workspace." + }, { "id": "api.templates.upgrade_request_subject", "translation": "Mattermost user request upgrade of workspace" @@ -3014,6 +3018,10 @@ "id": "api.templates.upgrade_request_title", "translation": "{{ .UserName }} would like to invite members to your workspace" }, + { + "id": "api.templates.upgrade_request_title2", + "translation": "New users are unable to join 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." @@ -6442,10 +6450,6 @@ "id": "ent.elasticsearch.not_started.error", "translation": "Elasticsearch is not started" }, - { - "id": "ent.elasticsearch.post.get_files_batch_for_indexing.error", - "translation": "Unable to get the files batch for indexing." - }, { "id": "ent.elasticsearch.post.get_posts_batch_for_indexing.error", "translation": "Unable to get the posts batch for indexing." diff --git a/model/client4.go b/model/client4.go index 8add017dce..8f4deac73d 100644 --- a/model/client4.go +++ b/model/client4.go @@ -5982,3 +5982,13 @@ func (c *Client4) SendAdminUpgradeRequestEmail() *Response { return BuildResponse(r) } + +func (c *Client4) SendAdminUpgradeRequestEmailOnJoin() *Response { + r, appErr := c.DoApiPost(c.GetCloudRoute()+"/subscription/limitreached/join", "") + if appErr != nil { + return BuildErrorResponse(r, appErr) + } + defer closeBody(r) + + return BuildResponse(r) +} diff --git a/model/cloud.go b/model/cloud.go index de7187e160..da5b754a71 100644 --- a/model/cloud.go +++ b/model/cloud.go @@ -6,6 +6,8 @@ package model const ( EventTypeFailedPayment = "failed-payment" EventTypeFailedPaymentNoCard = "failed-payment-no-card" + JoinLimitation = "join" + InviteLimitation = "invite" ) // Product model represents a product on the cloud system.