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 }}
+
+ |
+
+
+ |
+
+
+
+
+
+
+
+ |
+
+ {{.Props.Organization}}
+ {{.Props.Footer}}
+
+ |
+
+
+
+ |
+
+
+ |
+
+
+
+{{end}}