[MM-32543] - Add ability to send email to admin that users are trying to invite others (#16882)

* [MM-32543] - Add ability to send email to admin that users are trying to join

* Update email template

* Feedback impl-1

* Fix test

* Feedback impl-2

* Fix error

* Feedback impl

* Use 413 status code

* make i18n-extract

Co-authored-by: Mattermod <mattermod@users.noreply.github.com>
Этот коммит содержится в:
Allan Guwatudde
2021-02-16 15:03:08 +03:00
коммит произвёл GitHub
родитель d06a62ce64
Коммит 3f3abc3f3d
9 изменённых файлов: 313 добавлений и 13 удалений

Просмотреть файл

@@ -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)
}

Просмотреть файл

@@ -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

Просмотреть файл

@@ -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

Просмотреть файл

@@ -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)

Просмотреть файл

@@ -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")
}

Просмотреть файл

@@ -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")

Просмотреть файл

@@ -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."

Просмотреть файл

@@ -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)
}

88
templates/cloud_upgrade_request_email.html Обычный файл
Просмотреть файл

@@ -0,0 +1,88 @@
{{define "cloud_upgrade_request_email"}}
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%"
style="margin-top: 20px; line-height: 1.7; color: #555;font-family: Arial; font-style: normal; font-weight: bold;">
<tr>
<td>
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%"
style="max-width: 660px; font-family: Helvetica, Arial, sans-serif; font-size: 14px; background: #FFF;">
<tr>
<td style="border: 1px solid #ddd;">
<table align="left" border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td style="padding: 20px 20px 10px; text-align:left;">
<img src="{{.Props.SiteURL}}/static/images/logo-email.png" style="opacity: 0.5" width="130px" alt="">
</td>
</tr>
</table>
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%"
style="border-collapse: collapse; max-width: 443px">
<tr style="width: 443px">
<td>
<table border="0" cellpadding="0" cellspacing="0"
style="padding: 20px 0 0; text-align: center; margin: 0 auto">
<tr>
<td style="padding: 0 0 20px;">
<table align="center" border="0" cellpadding="0" cellspacing="0"
width="371px">
<tr>
<td>
<h2 style="margin-bottom: 0; text-align: center; color: #3D3C40; font-weight: bold; margin-top: 10px; line-height: 32px; font-size: 28px; font-family: Arial">{{ .Props.Title }}</h2>
</td>
</tr>
</table>
<p style="font-weight: normal; color: #3D3C40; font-size: 16px; line-height: 24px; font-family: Arial">
{{ .Props.Info4 }}</p>
<p style="margin: 31px 0 31px">
<a href="{{.Props.SiteURL}}/{{.Props.BillingPath}}" target="_blank"
style="font-weight: normal; border-radius: 4px; background: #166DE0; color: #fff;outline: none; min-width: 200px; padding: 11px 24px 11px 24px; font-size: 14px; font-family: Arial; cursor: pointer; -webkit-appearance: none;text-decoration: none; margin-top: 16px;">{{ .Props.Button }}</a>
</p>
<p style="font-weight: normal; line-height: 20px; color: #3D3C40; margin-bottom: 24px; font-size: 14px; font-family: Arial">
{{ .Props.Info5 }}</p>
<img src="{{.Props.SiteURL}}/static/images/credit-card-empty-state.png"
width="320px" alt="">
</td>
</tr>
</table>
</td>
</tr>
</table>
<table align="center" border="0" cellpadding="0" cellspcing="0" width="612px">
<tr>
<td style="border-bottom: 1px solid #E5E5E5"></td>
</tr>
</table>
<table align="left" border="0" cellpadding="0" cellspacing="0" width="100%"
style="max-width: 443px; font-family: Helvetica, Arial, sans-serif; font-size: 14px; background: #FFF; border-collapse: collapse;">
<tr>
<td style="font-family: Arial; padding: 20px 20px 24px 48px; text-align:left;">
<h3 style="margin-bottom: 0;">Questions?</h3>
<p style="font-weight: normal; margin-top: 0;">{{ .Props.EmailUs }} <a href="mailto:feedback@mattermost.com">feedback@mattermost.com</a></p>
</td>
</tr>
</table>
<table align="center" border="0" cellpadding="0" cellspcing="0" width="612px">
<tr>
<td style="border-bottom: 1px solid #E5E5E5"></td>
</tr>
</table>
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%"
style="font-family: Arial; line-height: 16px; font-size: 12px; background: #FFF;">
<tr>
<td
style="font-weight: normal; text-align: center;color: #AAA;font-size: 11px;padding-bottom: 10px;padding: 0;line-height: 16px;padding-bottom: 48px;padding-top: 4px;">
<p>
{{.Props.Organization}}<br>
{{.Props.Footer}}
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
{{end}}