From 3697f9204598a6426c6218a0fbd0a9b85ee09916 Mon Sep 17 00:00:00 2001 From: Nick Misasi Date: Mon, 26 Oct 2020 13:24:26 -0400 Subject: [PATCH] [MM-28363] User Limit Overage Warning Emails (#16053) * Adding files, commit of UI in good shape * Translations added, working with activation and deactivation * Add check for error * Fix i18n? * Push without subscription check so Steve and Matt can look at it * Fix font-weight in chrome * Fix font-weight on button * UX fixes * Fixes for PR * Add back subscription stuff * Fix tests Co-authored-by: Mattermod --- api4/user.go | 19 ++++++ app/app_iface.go | 1 + app/cloud.go | 57 +++++++++++++++++ app/email.go | 44 +++++++++++++ app/opentracing/opentracing_layer.go | 22 +++++++ i18n/en.json | 44 +++++++++++++ templates/reached_user_limit_body.html | 89 ++++++++++++++++++++++++++ 7 files changed, 276 insertions(+) create mode 100644 app/cloud.go create mode 100644 templates/reached_user_limit_body.html diff --git a/api4/user.go b/api4/user.go index b1552fb115..f53bf1db0f 100644 --- a/api4/user.go +++ b/api4/user.go @@ -155,6 +155,15 @@ func createUser(c *Context, w http.ResponseWriter, r *http.Request) { return } + // New user created, check cloud limits and send emails if needed + if ruser != nil { + err = c.App.CheckAndSendUserLimitWarningEmails() + if err != nil { + c.Err = err + return + } + } + auditRec.Success() auditRec.AddMeta("user", ruser) // overwrite meta @@ -1349,6 +1358,16 @@ func updateUserActive(c *Context, w http.ResponseWriter, r *http.Request) { message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_USER_ACTIVATION_STATUS_CHANGE, "", "", "", nil) c.App.Publish(message) + + // If activating, run cloud check for limit overages + if active { + emailErr := c.App.CheckAndSendUserLimitWarningEmails() + if emailErr != nil { + c.Err = emailErr + return + } + } + ReturnStatusOK(w) } diff --git a/app/app_iface.go b/app/app_iface.go index 3eb8ac9ed9..73f53f625d 100644 --- a/app/app_iface.go +++ b/app/app_iface.go @@ -388,6 +388,7 @@ type AppIface interface { CancelJob(jobId string) *model.AppError ChannelMembersToAdd(since int64, channelID *string) ([]*model.UserChannelIDPair, *model.AppError) ChannelMembersToRemove(teamID *string) ([]*model.ChannelMember, *model.AppError) + CheckAndSendUserLimitWarningEmails() *model.AppError CheckForClientSideCert(r *http.Request) (string, string, string) CheckPasswordAndAllCriteria(user *model.User, password string, mfaToken string) *model.AppError CheckRolesExist(roleNames []string) *model.AppError diff --git a/app/cloud.go b/app/cloud.go new file mode 100644 index 0000000000..6662ba9bf2 --- /dev/null +++ b/app/cloud.go @@ -0,0 +1,57 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "github.com/mattermost/mattermost-server/v5/model" +) + +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 + return nil + } + + subscription, subErr := a.Cloud().GetSubscription() + if subErr != nil { + return subErr + } + + if subscription != nil && subscription.IsPaidTier == "true" { + // Paid subscription, do nothing + return nil + } + + cloudUserLimit := *a.Config().ExperimentalSettings.CloudUserLimit + systemUserCount, _ := a.Srv().Store.User().Count(model.UserCountOptions{}) + remainingUsers := cloudUserLimit - systemUserCount + + if remainingUsers > 0 { + return nil + } + userOptions := &model.UserGetOptions{ + Page: 0, + PerPage: 100, + Role: model.SYSTEM_ADMIN_ROLE_ID, + Inactive: false, + } + sysAdmins, err := a.GetUsers(userOptions) + if err != nil { + return err + } + + // -1 means they are 1 user over the limit - we only want to send the email for the 11th user + if remainingUsers == -1 { + // Over limit by 1 user + for admin := range sysAdmins { + a.Srv().EmailService.SendOverUserLimitWarningEmail(sysAdmins[admin].Email, sysAdmins[admin].Locale, *a.Config().ServiceSettings.SiteURL) + } + } else if remainingUsers == 0 { + // At limit + for admin := range sysAdmins { + a.Srv().EmailService.SendAtUserLimitWarningEmail(sysAdmins[admin].Email, sysAdmins[admin].Locale, *a.Config().ServiceSettings.SiteURL) + } + } + return nil +} diff --git a/app/email.go b/app/email.go index e6cadc77a4..38fc181aa3 100644 --- a/app/email.go +++ b/app/email.go @@ -589,3 +589,47 @@ func (es *EmailService) CreateVerifyEmailToken(userId string, newEmail string) ( return token, nil } + +func (es *EmailService) SendAtUserLimitWarningEmail(email string, locale string, siteURL string) (bool, *model.AppError) { + T := utils.GetUserTranslations(locale) + + subject := T("api.templates.at_limit_subject") + + bodyPage := es.newEmailTemplate("reached_user_limit_body", locale) + bodyPage.Props["SiteURL"] = siteURL + bodyPage.Props["Title"] = T("api.templates.at_limit_title") + bodyPage.Props["Info1"] = T("api.templates.at_limit_info1") + bodyPage.Props["Info2"] = T("api.templates.at_limit_info2") + 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("SendAtUserLimitWarningEmail", "api.user.send_password_reset.send.app_error", nil, "err="+err.Message, http.StatusInternalServerError) + } + + return true, nil +} + +func (es *EmailService) SendOverUserLimitWarningEmail(email string, locale string, siteURL string) (bool, *model.AppError) { + T := utils.GetUserTranslations(locale) + + subject := T("api.templates.over_limit_subject") + + bodyPage := es.newEmailTemplate("reached_user_limit_body", locale) + bodyPage.Props["SiteURL"] = siteURL + bodyPage.Props["Title"] = T("api.templates.over_limit_title") + bodyPage.Props["Info1"] = T("api.templates.over_limit_info1") + bodyPage.Props["Info2"] = T("api.templates.over_limit_info2") + 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("SendOverUserLimitWarningEmail", "api.user.send_password_reset.send.app_error", nil, "err="+err.Message, http.StatusInternalServerError) + } + + return true, nil +} diff --git a/app/opentracing/opentracing_layer.go b/app/opentracing/opentracing_layer.go index 8e2f1109b4..16a2ec50cd 100644 --- a/app/opentracing/opentracing_layer.go +++ b/app/opentracing/opentracing_layer.go @@ -1010,6 +1010,28 @@ func (a *OpenTracingAppLayer) ChannelMembersToRemove(teamID *string) ([]*model.C return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) CheckAndSendUserLimitWarningEmails() *model.AppError { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.CheckAndSendUserLimitWarningEmails") + + 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.CheckAndSendUserLimitWarningEmails() + + if resultVar0 != nil { + span.LogFields(spanlog.Error(resultVar0)) + ext.Error.Set(span, true) + } + + return resultVar0 +} + func (a *OpenTracingAppLayer) CheckForClientSideCert(r *http.Request) (string, string, string) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.CheckForClientSideCert") diff --git a/i18n/en.json b/i18n/en.json index 5bff9c9faf..08cc91de0b 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -2586,6 +2586,26 @@ "id": "api.team.update_team_scheme.scheme_scope.error", "translation": "Unable to set the scheme to the team because the supplied scheme is not a team scheme." }, + { + "id": "api.templates.at_limit_info1", + "translation": "It looks like you have 10 or more users in your workspace now — that’s great! If you want to invite more team members, consider upgrading Mattermost Cloud Professional now." + }, + { + "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_subject", + "translation": "Mattermost Cloud User Limit Reached" + }, + { + "id": "api.templates.at_limit_title", + "translation": "You’ve reached the user limit for the free tier " + }, + { + "id": "api.templates.copyright", + "translation": "© 2017 Mattermost, Inc. 855 El Camino Real, 13A-168, Palo Alto, CA, 94301" + }, { "id": "api.templates.deactivate_body.info", "translation": "You deactivated your account on {{ .SiteURL }}." @@ -2650,6 +2670,10 @@ "id": "api.templates.email_organization", "translation": "Sent by " }, + { + "id": "api.templates.email_us_anytime_at", + "translation": "Email us any time at " + }, { "id": "api.templates.email_warning", "translation": "If you did not make this change, please contact the system administrator." @@ -2702,6 +2726,22 @@ "id": "api.templates.mfa_deactivated_body.title", "translation": "Multi-factor authentication was removed" }, + { + "id": "api.templates.over_limit_info1", + "translation": "It looks like you have more than 10 users in your workspace which is beyond the free tier limits of Mattermost Cloud Professional. To avoid any disruption in your Mattermost workspace, please upgrade." + }, + { + "id": "api.templates.over_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.over_limit_subject", + "translation": "Mattermost Cloud Workspace Over User Limit" + }, + { + "id": "api.templates.over_limit_title", + "translation": "Your workspace is over the user limit for the free tier" + }, { "id": "api.templates.password_change_body.info", "translation": "Your password has been updated for {{.TeamDisplayName}} on {{ .TeamURL }} by {{.Method}}." @@ -2766,6 +2806,10 @@ "id": "api.templates.signin_change_email.subject", "translation": "[{{ .SiteName }}] Your sign-in method has been updated" }, + { + "id": "api.templates.upgrade_mattermost_cloud", + "translation": "Upgrade" + }, { "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." diff --git a/templates/reached_user_limit_body.html b/templates/reached_user_limit_body.html new file mode 100644 index 0000000000..4d9d18b0a7 --- /dev/null +++ b/templates/reached_user_limit_body.html @@ -0,0 +1,89 @@ +{{define "reached_user_limit_body"}} + + + + + +
+ + + + +
+ + + + +
+ +
+ + + + +
+ + + + +
+ + + + +
+

{{ .Props.Title }}

+
+

+ {{ .Props.Info1 }}

+

+ {{ .Props.Button }} +

+

+ {{ .Props.Info2 }}

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

Questions?

+

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

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

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

+
+
+
+ +{{end}}