From 549e5b57cdae470d932878dd792e4f81ba72993c Mon Sep 17 00:00:00 2001 From: catalintomai <56169943+catalintomai@users.noreply.github.com> Date: Wed, 22 Jul 2020 20:32:21 -0700 Subject: [PATCH] Add metric warning support (announcement bar and DM) (#14483) * Admin. Advisory: Add warning for number of active users metric status Co-authored-by: Catalin Tomai --- api4/system.go | 61 +++++++ app/admin.go | 2 +- app/app.go | 252 +++++++++++++++++++++++++++ app/app_iface.go | 2 + app/bot.go | 45 +++++ app/diagnostics.go | 18 ++ app/email.go | 8 +- app/integration_action.go | 142 ++++++++++++++- app/opentracing/opentracing_layer.go | 44 +++++ app/security_update_check.go | 2 +- app/server.go | 59 +++++++ i18n/en.json | 144 +++++++++++++++ model/bot.go | 7 +- model/config.go | 1 + model/post.go | 4 +- model/system.go | 105 +++++++++-- model/websocket_message.go | 2 + services/mailservice/mail.go | 12 +- services/mailservice/mail_test.go | 8 +- templates/warn_metric_ack.html | 84 +++++++++ web/params.go | 5 + 21 files changed, 979 insertions(+), 28 deletions(-) create mode 100644 templates/warn_metric_ack.html diff --git a/api4/system.go b/api4/system.go index f17667ae79..ac754a9cd3 100644 --- a/api4/system.go +++ b/api4/system.go @@ -54,6 +54,9 @@ func (api *API) InitSystem() { api.BaseRoutes.ApiRoot.Handle("/server_busy", api.ApiSessionRequired(setServerBusy)).Methods("POST") api.BaseRoutes.ApiRoot.Handle("/server_busy", api.ApiSessionRequired(getServerBusyExpires)).Methods("GET") api.BaseRoutes.ApiRoot.Handle("/server_busy", api.ApiSessionRequired(clearServerBusy)).Methods("DELETE") + + api.BaseRoutes.ApiRoot.Handle("/warn_metrics/status", api.ApiSessionRequired(getWarnMetricsStatus)).Methods("GET") + api.BaseRoutes.ApiRoot.Handle("/warn_metrics/ack/{warn_metric_id:[A-Za-z0-9-_]+}", api.ApiHandler(sendWarnMetricAckEmail)).Methods("POST") } func getSystemPing(c *Context, w http.ResponseWriter, r *http.Request) { @@ -544,3 +547,61 @@ func getServerBusyExpires(c *Context, w http.ResponseWriter, r *http.Request) { } w.Write([]byte(c.App.Srv().Busy.ToJson())) } + +func getWarnMetricsStatus(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.App.SessionHasPermissionTo(*c.App.Session(), model.PERMISSION_MANAGE_SYSTEM) { + c.SetPermissionError(model.PERMISSION_MANAGE_SYSTEM) + return + } + + license := c.App.Srv().License() + if license != nil { + mlog.Debug("License is present, skip.") + return + } + + status, err := c.App.GetWarnMetricsStatus() + if err != nil { + c.Err = err + return + } + + w.Write([]byte(model.MapWarnMetricStatusToJson(status))) +} + +func sendWarnMetricAckEmail(c *Context, w http.ResponseWriter, r *http.Request) { + auditRec := c.MakeAuditRecord("sendWarnMetricAckEmail", audit.Fail) + defer c.LogAuditRec(auditRec) + c.LogAudit("attempt") + + if !c.App.SessionHasPermissionTo(*c.App.Session(), model.PERMISSION_MANAGE_SYSTEM) { + c.SetPermissionError(model.PERMISSION_MANAGE_SYSTEM) + return + } + + license := c.App.Srv().License() + if license != nil { + mlog.Debug("License is present, skip.") + return + } + + user, appErr := c.App.GetUser(c.App.Session().UserId) + if appErr != nil { + c.Err = appErr + return + } + + ack := model.SendWarnMetricAckFromJson(r.Body) + if ack == nil { + c.SetInvalidParam("ack") + return + } + + appErr = c.App.NotifyAndSetWarnMetricAck(c.Params.WarnMetricId, user, ack.ForceAck, false) + if appErr != nil { + c.Err = appErr + } + + auditRec.Success() + ReturnStatusOK(w) +} diff --git a/app/admin.go b/app/admin.go index 20a57c3004..9e3191e388 100644 --- a/app/admin.go +++ b/app/admin.go @@ -221,7 +221,7 @@ func (a *App) TestEmail(userId string, cfg *model.Config) *model.AppError { T := utils.GetUserTranslations(user.Locale) license := a.Srv().License() - if err := mailservice.SendMailUsingConfig(user.Email, T("api.admin.test_email.subject"), T("api.admin.test_email.body"), cfg, license != nil && *license.Features.Compliance); err != nil { + if err := mailservice.SendMailUsingConfig(user.Email, T("api.admin.test_email.subject"), T("api.admin.test_email.body"), cfg, license != nil && *license.Features.Compliance, ""); err != nil { return model.NewAppError("testEmail", "app.admin.test_email.failure", map[string]interface{}{"Error": err.Error()}, "", http.StatusInternalServerError) } diff --git a/app/app.go b/app/app.go index 706db11ba1..a5d27c4c96 100644 --- a/app/app.go +++ b/app/app.go @@ -5,16 +5,20 @@ package app import ( "context" + "fmt" "html/template" "net/http" "strconv" + "strings" + "github.com/mattermost/go-i18n/i18n" goi18n "github.com/mattermost/go-i18n/i18n" "github.com/mattermost/mattermost-server/v5/einterfaces" "github.com/mattermost/mattermost-server/v5/mlog" "github.com/mattermost/mattermost-server/v5/model" "github.com/mattermost/mattermost-server/v5/services/httpservice" "github.com/mattermost/mattermost-server/v5/services/imageproxy" + "github.com/mattermost/mattermost-server/v5/services/mailservice" "github.com/mattermost/mattermost-server/v5/services/searchengine" "github.com/mattermost/mattermost-server/v5/services/timezones" "github.com/mattermost/mattermost-server/v5/utils" @@ -107,6 +111,7 @@ func (a *App) InitServer() { if a.Srv().runjobs { a.Srv().Go(func() { runLicenseExpirationCheckJob(a) + runCheckNumberOfActiveUsersWarnMetricStatusJob(a) }) } a.srv.RunJobs() @@ -184,6 +189,253 @@ func (s *Server) getFirstServerRunTimestamp() (int64, *model.AppError) { return value, nil } +func (a *App) GetWarnMetricsStatus() (map[string]*model.WarnMetricStatus, *model.AppError) { + systemDataList, appErr := a.Srv().Store.System().Get() + if appErr != nil { + return nil, appErr + } + + result := map[string]*model.WarnMetricStatus{} + for key, value := range systemDataList { + if strings.HasPrefix(key, model.WARN_METRIC_STATUS_STORE_PREFIX) { + if warnMetric, ok := model.WarnMetricsTable[key]; ok { + if !warnMetric.IsBotOnly && value == model.WARN_METRIC_STATUS_LIMIT_REACHED { + result[key], _ = a.getWarnMetricStatusAndDisplayTextsForId(key, nil) + } + } + } + } + + return result, nil +} + +func (a *App) getWarnMetricStatusAndDisplayTextsForId(warnMetricId string, T i18n.TranslateFunc) (*model.WarnMetricStatus, *model.WarnMetricDisplayTexts) { + var warnMetricStatus *model.WarnMetricStatus + var warnMetricDisplayTexts = &model.WarnMetricDisplayTexts{} + + if warnMetric, ok := model.WarnMetricsTable[warnMetricId]; ok { + warnMetricStatus = &model.WarnMetricStatus{ + Id: warnMetric.Id, + Limit: warnMetric.Limit, + Acked: false, + } + + if T == nil { + mlog.Debug("No translation function") + return warnMetricStatus, nil + } + + warnMetricDisplayTexts.BotMailToBody = T("api.server.warn_metric.bot_response.number_of_users.mailto_body", map[string]interface{}{"Limit": warnMetric.Limit}) + warnMetricDisplayTexts.EmailBody = T("api.templates.warn_metric_ack.number_of_active_users.body", map[string]interface{}{"Limit": warnMetric.Limit}) + + switch warnMetricId { + case model.SYSTEM_WARN_METRIC_NUMBER_OF_ACTIVE_USERS_200: + warnMetricDisplayTexts.BotTitle = T("api.server.warn_metric.number_of_active_users_200.notification_title") + warnMetricDisplayTexts.BotMessageBody = T("api.server.warn_metric.number_of_active_users_200.notification_body") + case model.SYSTEM_WARN_METRIC_NUMBER_OF_ACTIVE_USERS_400: + warnMetricDisplayTexts.BotTitle = T("api.server.warn_metric.number_of_active_users_400.notification_title") + warnMetricDisplayTexts.BotMessageBody = T("api.server.warn_metric.number_of_active_users_400.notification_body") + case model.SYSTEM_WARN_METRIC_NUMBER_OF_ACTIVE_USERS_500: + warnMetricDisplayTexts.BotTitle = T("api.server.warn_metric.number_of_active_users_500.notification_title") + warnMetricDisplayTexts.BotMessageBody = T("api.server.warn_metric.number_of_active_users_500.notification_body") + default: + mlog.Error("Invalid metric id", mlog.String("id", warnMetricId)) + return nil, nil + } + + return warnMetricStatus, warnMetricDisplayTexts + } + return nil, nil +} + +func (a *App) notifyAdminsOfWarnMetricStatus(warnMetricId string) *model.AppError { + perPage := 25 + userOptions := &model.UserGetOptions{ + Page: 0, + PerPage: perPage, + Role: model.SYSTEM_ADMIN_ROLE_ID, + Inactive: false, + } + + // get sysadmins + var sysAdmins []*model.User + for { + sysAdminsList, err := a.GetUsers(userOptions) + if err != nil { + return err + } + + if len(sysAdminsList) == 0 { + return model.NewAppError("NotifyAdminsOfWarnMetricStatus", "app.system.warn_metric.notification.empty_admin_list.app_error", nil, err.Error(), http.StatusInternalServerError) + } + sysAdmins = append(sysAdmins, sysAdminsList...) + + if len(sysAdminsList) < perPage { + mlog.Debug("Number of system admins is less than page limit", mlog.Int("count", len(sysAdminsList))) + break + } + } + + T := utils.GetUserTranslations(sysAdmins[0].Locale) + warnMetricsBot := &model.Bot{ + Username: model.BOT_WARN_METRIC_BOT_USERNAME, + DisplayName: T("app.system.warn_metric.bot_displayname"), + Description: "", + OwnerId: sysAdmins[0].Id, + } + + bot, err := a.getOrCreateWarnMetricsBot(warnMetricsBot) + if err != nil { + return err + } + + for _, sysAdmin := range sysAdmins { + T := utils.GetUserTranslations(sysAdmin.Locale) + bot.DisplayName = T("app.system.warn_metric.bot_displayname") + bot.Description = T("app.system.warn_metric.bot_description") + + channel, appErr := a.GetOrCreateDirectChannel(bot.UserId, sysAdmin.Id) + if appErr != nil { + mlog.Error("Cannot create channel for system bot notification!", mlog.String("Admin Id", sysAdmin.Id)) + return appErr + } + + warnMetricStatus, warnMetricDisplayTexts := a.getWarnMetricStatusAndDisplayTextsForId(warnMetricId, T) + if warnMetricStatus == nil { + return model.NewAppError("NotifyAdminsOfWarnMetricStatus", "app.system.warn_metric.notification.invalid_metric.app_error", nil, "", http.StatusInternalServerError) + } + + botPost := &model.Post{ + UserId: bot.UserId, + ChannelId: channel.Id, + Type: model.POST_SYSTEM_WARN_METRIC_STATUS, + Message: "", + } + + actions := []*model.PostAction{} + actions = append(actions, + &model.PostAction{ + Id: "contactUs", + Name: T("api.server.warn_metric.contact_us"), + Type: model.POST_ACTION_TYPE_BUTTON, + Options: []*model.PostActionOptions{ + { + Text: "TrackEventId", + Value: warnMetricId, + }, + { + Text: "ActionExecutingMessage", + Value: T("api.server.warn_metric.contacting_us"), + }, + }, + Integration: &model.PostActionIntegration{ + Context: model.StringInterface{ + "bot_user_id": bot.UserId, + "force_ack": false, + }, + URL: fmt.Sprintf("/warn_metrics/ack/%s", warnMetricId), + }, + }, + ) + + attachments := []*model.SlackAttachment{{ + AuthorName: "", + Title: warnMetricDisplayTexts.BotTitle, + Text: warnMetricDisplayTexts.BotMessageBody, + Actions: actions, + }} + model.ParseSlackAttachment(botPost, attachments) + + mlog.Debug("Send admin advisory for metric", mlog.String("warnMetricId", warnMetricId), mlog.String("userid", botPost.UserId)) + if _, err := a.CreatePostAsUser(botPost, a.Session().Id, true); err != nil { + return err + } + } + + return nil +} + +func (a *App) NotifyAndSetWarnMetricAck(warnMetricId string, sender *model.User, forceAck bool, isBot bool) *model.AppError { + if warnMetric, ok := model.WarnMetricsTable[warnMetricId]; ok { + data, err := a.Srv().Store.System().GetByName(warnMetric.Id) + if err == nil && data != nil && data.Value == model.WARN_METRIC_STATUS_ACK { + mlog.Debug("This metric warning has already been acknowledged") + return nil + } + + if !forceAck { + if len(*a.Config().EmailSettings.SMTPServer) == 0 { + return model.NewAppError("NotifyAndSetWarnMetricAck", "api.email.send_warn_metric_ack.missing_server.app_error", nil, utils.T("api.context.invalid_param.app_error", map[string]interface{}{"Name": "SMTPServer"}), http.StatusInternalServerError) + } + T := utils.GetUserTranslations(sender.Locale) + bodyPage := a.Srv().EmailService.newEmailTemplate("warn_metric_ack", sender.Locale) + bodyPage.Props["ContactNameHeader"] = T("api.templates.warn_metric_ack.body.contact_name_header") + bodyPage.Props["ContactNameValue"] = sender.GetFullName() + bodyPage.Props["ContactEmailHeader"] = T("api.templates.warn_metric_ack.body.contact_email_header") + bodyPage.Props["ContactEmailValue"] = sender.Email + + //same definition as the active users count metric displayed in the SystemConsole Analytics section + registeredUsersCount, cerr := a.Srv().Store.User().Count(model.UserCountOptions{}) + if cerr != nil { + mlog.Error("Error retrieving the number of registered users", mlog.Err(cerr)) + } else { + bodyPage.Props["RegisteredUsersHeader"] = T("api.templates.warn_metric_ack.body.registered_users_header") + bodyPage.Props["RegisteredUsersValue"] = registeredUsersCount + } + bodyPage.Props["SiteURLHeader"] = T("api.templates.warn_metric_ack.body.site_url_header") + bodyPage.Props["SiteURL"] = a.GetSiteURL() + bodyPage.Props["DiagnosticIdHeader"] = T("api.templates.warn_metric_ack.body.diagnostic_id_header") + bodyPage.Props["DiagnosticIdValue"] = a.DiagnosticId() + bodyPage.Props["Footer"] = T("api.templates.warn_metric_ack.footer") + + warnMetricStatus, warnMetricDisplayTexts := a.getWarnMetricStatusAndDisplayTextsForId(warnMetricId, T) + if warnMetricStatus == nil { + return model.NewAppError("NotifyAndSetWarnMetricAck", "api.email.send_warn_metric_ack.invalid_warn_metric.app_error", nil, "", http.StatusInternalServerError) + } + + subject := T("api.templates.warn_metric_ack.subject") + bodyPage.Props["Title"] = warnMetricDisplayTexts.EmailBody + + if err = mailservice.SendMailUsingConfig(model.MM_SUPPORT_ADDRESS, subject, bodyPage.Render(), a.Config(), false, sender.Email); err != nil { + mlog.Error("Error while sending email", mlog.String("destination email", model.MM_SUPPORT_ADDRESS), mlog.Err(err)) + return model.NewAppError("NotifyAndSetWarnMetricAck", "api.email.send_warn_metric_ack.failure.app_error", map[string]interface{}{"Error": err.Error()}, "", http.StatusInternalServerError) + } + } + + mlog.Debug("Disable the monitoring of all warn metrics") + err = a.setWarnMetricsStatus(model.WARN_METRIC_STATUS_ACK) + if err != nil { + return err + } + + if !warnMetric.IsBotOnly && !isBot { + message := model.NewWebSocketEvent(model.WEBSOCKET_WARN_METRIC_STATUS_REMOVED, "", "", "", nil) + message.Add("warnMetricId", warnMetric.Id) + a.Publish(message) + } + } + return nil +} + +func (a *App) setWarnMetricsStatus(status string) *model.AppError { + for _, warnMetric := range model.WarnMetricsTable { + a.setWarnMetricsStatusForId(warnMetric.Id, status) + } + return nil +} + +func (a *App) setWarnMetricsStatusForId(warnMetricId string, status string) *model.AppError { + mlog.Info("Storing user acknowledgement for warn metric", mlog.String("warnMetricId", warnMetricId)) + if err := a.Srv().Store.System().SaveOrUpdate(&model.System{ + Name: warnMetricId, + Value: status, + }); err != nil { + mlog.Error("Unable to write to database.", mlog.Err(err)) + return model.NewAppError("setWarnMetricsStatusForId", "app.system.warn_metric.store.app_error", map[string]interface{}{"WarnMetricName": warnMetricId}, "", http.StatusInternalServerError) + } + return nil +} + func (a *App) Srv() *Server { return a.srv } diff --git a/app/app_iface.go b/app/app_iface.go index b46934d2c6..66afab35b8 100644 --- a/app/app_iface.go +++ b/app/app_iface.go @@ -692,6 +692,7 @@ type AppIface interface { GetUsersWithoutTeamPage(options *model.UserGetOptions, asAdmin bool) ([]*model.User, *model.AppError) GetVerifyEmailToken(token string) (*model.Token, *model.AppError) GetViewUsersRestrictions(userId string) (*model.ViewUsersRestrictions, *model.AppError) + GetWarnMetricsStatus() (map[string]*model.WarnMetricStatus, *model.AppError) HTTPService() httpservice.HTTPService Handle404(w http.ResponseWriter, r *http.Request) HandleCommandResponse(command *model.Command, args *model.CommandArgs, response *model.CommandResponse, builtIn bool) (*model.CommandResponse, *model.AppError) @@ -753,6 +754,7 @@ type AppIface interface { NewPluginAPI(manifest *model.Manifest) plugin.API Notification() einterfaces.NotificationInterface NotificationsLog() *mlog.Logger + NotifyAndSetWarnMetricAck(warnMetricId string, sender *model.User, forceAck bool, isBot bool) *model.AppError OpenInteractiveDialog(request model.OpenDialogRequest) *model.AppError OriginChecker() func(*http.Request) bool PatchChannel(channel *model.Channel, patch *model.ChannelPatch, userId string) (*model.Channel, *model.AppError) diff --git a/app/bot.go b/app/bot.go index 9b53a4e5da..6f22f34a57 100644 --- a/app/bot.go +++ b/app/bot.go @@ -65,6 +65,51 @@ func (a *App) CreateBot(bot *model.Bot) (*model.Bot, *model.AppError) { return savedBot, nil } +func (a *App) getOrCreateWarnMetricsBot(botDef *model.Bot) (*model.Bot, *model.AppError) { + botUser, appErr := a.GetUserByUsername(botDef.Username) + if appErr != nil { + if appErr.StatusCode != http.StatusNotFound { + mlog.Error(appErr.Error()) + return nil, appErr + } + + // cannot find this bot user, save the user + user, err := a.Srv().Store.User().Save(model.UserFromBot(botDef)) + if err != nil { + mlog.Error(err.Error()) + return nil, err + } + botDef.UserId = user.Id + + //save the bot + savedBot, nErr := a.Srv().Store.Bot().Save(botDef) + if nErr != nil { + a.Srv().Store.User().PermanentDelete(savedBot.UserId) + var nAppErr *model.AppError + switch { + case errors.As(nErr, &nAppErr): // in case we haven't converted to plain error. + return nil, nAppErr + default: // last fallback in case it doesn't map to an existing app error. + return nil, model.NewAppError("getOrCreateWarnMetricsBot", "app.bot.createbot.internal_error", nil, nErr.Error(), http.StatusInternalServerError) + } + } + return savedBot, nil + } + + if botUser == nil { + return nil, model.NewAppError("getOrCreateWarnMetricsBot", "app.bot.createbot.internal_error", nil, "", http.StatusInternalServerError) + } + + //return the bot for this user + savedBot, appErr := a.GetBot(botUser.Id, false) + if appErr != nil { + mlog.Error(appErr.Error()) + return nil, appErr + } + + return savedBot, nil +} + // PatchBot applies the given patch to the bot and corresponding user. func (a *App) PatchBot(botUserId string, botPatch *model.BotPatch) (*model.Bot, *model.AppError) { bot, err := a.GetBot(botUserId, true) diff --git a/app/diagnostics.go b/app/diagnostics.go index 6e6bbc7f67..6a701d38a8 100644 --- a/app/diagnostics.go +++ b/app/diagnostics.go @@ -57,6 +57,7 @@ const ( TRACK_ELASTICSEARCH = "elasticsearch" TRACK_GROUPS = "groups" TRACK_CHANNEL_MODERATION = "channel_moderation" + TRACK_WARN_METRICS = "warn_metrics" TRACK_ACTIVITY = "activity" TRACK_LICENSE = "license" @@ -83,6 +84,7 @@ func (s *Server) sendDailyDiagnostics(override bool) { s.trackElasticsearch() s.trackGroups() s.trackChannelModeration() + s.trackWarnMetrics() } } @@ -1088,3 +1090,19 @@ func (s *Server) trackChannelModeration() { "use_channel_mentions_guest_disabled_count": useChannelMentionsGuest, }) } + +func (s *Server) trackWarnMetrics() { + systemDataList, appErr := s.Store.System().Get() + if appErr != nil { + return + } + for key, value := range systemDataList { + if strings.HasPrefix(key, model.WARN_METRIC_STATUS_STORE_PREFIX) { + if _, ok := model.WarnMetricsTable[key]; ok { + s.SendDiagnostic(TRACK_WARN_METRICS, map[string]interface{}{ + key: value != "false", + }) + } + } + } +} diff --git a/app/email.go b/app/email.go index c358b79d74..f4413f187e 100644 --- a/app/email.go +++ b/app/email.go @@ -550,15 +550,19 @@ func (es *EmailService) sendNotificationMail(to, subject, htmlBody string) *mode } func (es *EmailService) sendMail(to, subject, htmlBody string) *model.AppError { + return es.sendMailWithCC(to, subject, htmlBody, "") +} + +func (es *EmailService) sendMailWithCC(to, subject, htmlBody string, ccMail string) *model.AppError { license := es.srv.License() - return mailservice.SendMailUsingConfig(to, subject, htmlBody, es.srv.Config(), license != nil && *license.Features.Compliance) + return mailservice.SendMailUsingConfig(to, subject, htmlBody, es.srv.Config(), license != nil && *license.Features.Compliance, ccMail) } func (es *EmailService) sendMailWithEmbeddedFiles(to, subject, htmlBody string, embeddedFiles map[string]io.Reader) *model.AppError { license := es.srv.License() config := es.srv.Config() - return mailservice.SendMailWithEmbeddedFilesUsingConfig(to, subject, htmlBody, embeddedFiles, config, license != nil && *license.Features.Compliance) + return mailservice.SendMailWithEmbeddedFilesUsingConfig(to, subject, htmlBody, embeddedFiles, config, license != nil && *license.Features.Compliance, "") } func (es *EmailService) CreateVerifyEmailToken(userId string, newEmail string) (*model.Token, *model.AppError) { diff --git a/app/integration_action.go b/app/integration_action.go index cf4e027d52..7e44e09c42 100644 --- a/app/integration_action.go +++ b/app/integration_action.go @@ -26,10 +26,12 @@ import ( "net/http" "net/url" "path" + "path/filepath" "strings" "github.com/gorilla/mux" + "github.com/mattermost/mattermost-server/v5/mlog" "github.com/mattermost/mattermost-server/v5/model" "github.com/mattermost/mattermost-server/v5/store" "github.com/mattermost/mattermost-server/v5/utils" @@ -211,11 +213,20 @@ func (a *App) DoPostActionWithCookie(postId, actionId, userId, selectedOption st return "", appErr } - resp, appErr := a.DoActionRequest(upstreamURL, upstreamRequest.ToJson()) - if appErr != nil { - return "", appErr + var resp *http.Response + if strings.HasPrefix(upstreamURL, "/warn_metrics/") { + appErr = a.doLocalWarnMetricsRequest(upstreamURL, upstreamRequest) + if appErr != nil { + return "", appErr + } + return "", nil + } else { + resp, appErr = a.DoActionRequest(upstreamURL, upstreamRequest.ToJson()) + if appErr != nil { + return "", appErr + } + defer resp.Body.Close() } - defer resp.Body.Close() var response model.PostActionIntegrationResponse if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { @@ -398,6 +409,129 @@ func (a *App) doPluginRequest(method, rawURL string, values url.Values, body []b return resp, nil } +func (a *App) doLocalWarnMetricsRequest(rawURL string, upstreamRequest *model.PostActionIntegrationRequest) *model.AppError { + _, err := url.Parse(rawURL) + if err != nil { + return model.NewAppError("doLocalWarnMetricsRequest", "api.post.do_action.action_integration.app_error", nil, err.Error(), http.StatusBadRequest) + } + + warnMetricId := filepath.Base(rawURL) + if warnMetricId == "" { + return model.NewAppError("doLocalWarnMetricsRequest", "api.post.do_action.action_integration.app_error", nil, "", http.StatusBadRequest) + } + + user, appErr := a.GetUser(a.Session().UserId) + if appErr != nil { + return appErr + } + + botPost := &model.Post{ + UserId: upstreamRequest.Context["bot_user_id"].(string), + ChannelId: upstreamRequest.ChannelId, + HasReactions: true, + } + + forceAck := upstreamRequest.Context["force_ack"].(bool) + + if appErr = a.NotifyAndSetWarnMetricAck(warnMetricId, user, forceAck, true); appErr != nil { + if forceAck { + return appErr + } + mailtoLinkText := a.buildWarnMetricMailtoLink(warnMetricId, user) + botPost.Message = ":warning: " + utils.T("api.server.warn_metric.bot_response.notification_failure.message") + actions := []*model.PostAction{} + actions = append(actions, + &model.PostAction{ + Id: "emailUs", + Name: utils.T("api.server.warn_metric.email_us"), + Type: model.POST_ACTION_TYPE_BUTTON, + Options: []*model.PostActionOptions{ + { + Text: "WarnMetricMailtoUrl", + Value: mailtoLinkText, + }, + { + Text: "TrackEventId", + Value: warnMetricId, + }, + }, + Integration: &model.PostActionIntegration{ + Context: model.StringInterface{ + "bot_user_id": botPost.UserId, + "force_ack": true, + }, + URL: fmt.Sprintf("/warn_metrics/ack/%s", model.SYSTEM_WARN_METRIC_NUMBER_OF_ACTIVE_USERS_500), + }, + }, + ) + attachements := []*model.SlackAttachment{{ + AuthorName: "", + Title: "", + Actions: actions, + Text: utils.T("api.server.warn_metric.bot_response.notification_failure.body"), + }} + model.ParseSlackAttachment(botPost, attachements) + } else { + botPost.Message = ":white_check_mark: " + utils.T("api.server.warn_metric.bot_response.notification_success.message") + } + + if _, err := a.CreatePostAsUser(botPost, a.Session().Id, true); err != nil { + return err + } + + return nil +} + +type MailToLinkContent struct { + MetricId string `json:"metric_id"` + MailRecipient string `json:"mail_recipient"` + MailCC string `json:"mail_cc"` + MailSubject string `json:"mail_subject"` + MailBody string `json:"mail_body"` +} + +func (mlc *MailToLinkContent) ToJson() string { + b, _ := json.Marshal(mlc) + return string(b) +} + +func (a *App) buildWarnMetricMailtoLink(warnMetricId string, user *model.User) string { + T := utils.GetUserTranslations(user.Locale) + _, warnMetricDisplayTexts := a.getWarnMetricStatusAndDisplayTextsForId(warnMetricId, T) + + mailBody := warnMetricDisplayTexts.BotMailToBody + mailBody += T("api.server.warn_metric.bot_response.mailto_contact_header", map[string]interface{}{"Contact": user.GetFullName()}) + mailBody += "\r\n" + mailBody += T("api.server.warn_metric.bot_response.mailto_email_header", map[string]interface{}{"Email": user.Email}) + mailBody += "\r\n" + + registeredUsersCount, err := a.Srv().Store.User().Count(model.UserCountOptions{}) + if err != nil { + mlog.Error("Error retrieving the number of registered users", mlog.Err(err)) + } else { + mailBody += utils.T("api.server.warn_metric.bot_response.mailto_registered_users_header", map[string]interface{}{"NoRegisteredUsers": registeredUsersCount}) + mailBody += "\r\n" + } + + mailBody += T("api.server.warn_metric.bot_response.mailto_site_url_header", map[string]interface{}{"SiteUrl": a.GetSiteURL()}) + mailBody += "\r\n" + + mailBody += T("api.server.warn_metric.bot_response.mailto_diagnostic_id_header", map[string]interface{}{"DiagnosticId": a.DiagnosticId()}) + mailBody += "\r\n" + + mailBody += T("api.server.warn_metric.bot_response.mailto_footer") + + mailToLinkContent := &MailToLinkContent{ + MetricId: warnMetricId, + MailRecipient: "support@mattermost.com", + MailCC: user.Email, + MailSubject: T("api.server.warn_metric.bot_response.mailto_subject"), + MailBody: mailBody, + } + + return mailToLinkContent.ToJson() +} + func (a *App) DoLocalRequest(rawURL string, body []byte) (*http.Response, *model.AppError) { return a.doPluginRequest("POST", rawURL, nil, body) } diff --git a/app/opentracing/opentracing_layer.go b/app/opentracing/opentracing_layer.go index 10a044f339..6a285a3fef 100644 --- a/app/opentracing/opentracing_layer.go +++ b/app/opentracing/opentracing_layer.go @@ -8940,6 +8940,28 @@ func (a *OpenTracingAppLayer) GetViewUsersRestrictions(userId string) (*model.Vi return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) GetWarnMetricsStatus() (map[string]*model.WarnMetricStatus, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetWarnMetricsStatus") + + a.ctx = newCtx + a.app.Srv().Store.SetContext(newCtx) + defer func() { + a.app.Srv().Store.SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.GetWarnMetricsStatus() + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) Handle404(w http.ResponseWriter, r *http.Request) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.Handle404") @@ -10255,6 +10277,28 @@ func (a *OpenTracingAppLayer) NewWebHub() *app.Hub { return resultVar0 } +func (a *OpenTracingAppLayer) NotifyAndSetWarnMetricAck(warnMetricId string, sender *model.User, forceAck bool, isBot bool) *model.AppError { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.NotifyAndSetWarnMetricAck") + + 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.NotifyAndSetWarnMetricAck(warnMetricId, sender, forceAck, isBot) + + if resultVar0 != nil { + span.LogFields(spanlog.Error(resultVar0)) + ext.Error.Set(span, true) + } + + return resultVar0 +} + func (a *OpenTracingAppLayer) NotifySessionsExpired() *model.AppError { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.NotifySessionsExpired") diff --git a/app/security_update_check.go b/app/security_update_check.go index 071d00e7ff..d918623614 100644 --- a/app/security_update_check.go +++ b/app/security_update_check.go @@ -115,7 +115,7 @@ func (s *Server) DoSecurityUpdateCheck() { for _, user := range users { mlog.Info("Sending security bulletin", mlog.String("bulletin_id", bulletin.Id), mlog.String("user_email", user.Email)) license := s.License() - mailservice.SendMailUsingConfig(user.Email, utils.T("mattermost.bulletin.subject"), string(body), s.Config(), license != nil && *license.Features.Compliance) + mailservice.SendMailUsingConfig(user.Email, utils.T("mattermost.bulletin.subject"), string(body), s.Config(), license != nil && *license.Features.Compliance, "") } bulletinSeen := &model.System{Name: "SecurityBulletin_" + bulletin.Id, Value: bulletin.Id} diff --git a/app/server.go b/app/server.go index 94b9ad4c0b..1659fc7ee3 100644 --- a/app/server.go +++ b/app/server.go @@ -1090,6 +1090,13 @@ func runLicenseExpirationCheckJob(a *App) { }, time.Hour*24) } +func runCheckNumberOfActiveUsersWarnMetricStatusJob(a *App) { + doCheckNumberOfActiveUsersWarnMetricStatus(a) + model.CreateRecurringTask("Check Number Of Active Users Warn Metric Status", func() { + doCheckNumberOfActiveUsersWarnMetricStatus(a) + }, time.Hour*24) +} + func doSecurity(s *Server) { s.DoSecurityUpdateCheck() } @@ -1117,6 +1124,58 @@ func doSessionCleanup(s *Server) { s.Store.Session().Cleanup(model.GetMillis(), SESSIONS_CLEANUP_BATCH_SIZE) } +func doCheckNumberOfActiveUsersWarnMetricStatus(a *App) { + license := a.Srv().License() + if license != nil { + mlog.Debug("License is present, skip this check") + return + } + + numberOfActiveUsers, err := a.Srv().Store.User().Count(model.UserCountOptions{}) + if err != nil { + mlog.Error("Error to get active registered users.", mlog.Err(err)) + } + + warnMetrics := []model.WarnMetric{} + if numberOfActiveUsers < model.WarnMetricsTable[model.SYSTEM_WARN_METRIC_NUMBER_OF_ACTIVE_USERS_200].Limit { + return + } else if numberOfActiveUsers >= model.WarnMetricsTable[model.SYSTEM_WARN_METRIC_NUMBER_OF_ACTIVE_USERS_200].Limit && numberOfActiveUsers < model.WarnMetricsTable[model.SYSTEM_WARN_METRIC_NUMBER_OF_ACTIVE_USERS_400].Limit { + warnMetrics = append(warnMetrics, model.WarnMetricsTable[model.SYSTEM_WARN_METRIC_NUMBER_OF_ACTIVE_USERS_200]) + } else if numberOfActiveUsers >= model.WarnMetricsTable[model.SYSTEM_WARN_METRIC_NUMBER_OF_ACTIVE_USERS_400].Limit && numberOfActiveUsers < model.WarnMetricsTable[model.SYSTEM_WARN_METRIC_NUMBER_OF_ACTIVE_USERS_500].Limit { + warnMetrics = append(warnMetrics, model.WarnMetricsTable[model.SYSTEM_WARN_METRIC_NUMBER_OF_ACTIVE_USERS_400]) + } else { + warnMetrics = append(warnMetrics, model.WarnMetricsTable[model.SYSTEM_WARN_METRIC_NUMBER_OF_ACTIVE_USERS_500]) + } + + for _, warnMetric := range warnMetrics { + data, err := a.Srv().Store.System().GetByName(warnMetric.Id) + if err == nil && data != nil && (data.Value == model.WARN_METRIC_STATUS_ACK || data.Value == model.WARN_METRIC_STATUS_RUNONCE) { + mlog.Debug("This metric warning has already been acked or should only run once") + continue + } + + if err = a.Srv().Store.System().SaveOrUpdate(&model.System{Name: warnMetric.Id, Value: model.WARN_METRIC_STATUS_LIMIT_REACHED}); err != nil { + mlog.Error("Unable to write to database.", mlog.String("id", warnMetric.Id), mlog.Err(err)) + continue + } + warnMetricStatus, _ := a.getWarnMetricStatusAndDisplayTextsForId(warnMetric.Id, nil) + + if !warnMetric.IsBotOnly { + message := model.NewWebSocketEvent(model.WEBSOCKET_WARN_METRIC_STATUS_RECEIVED, "", "", "", nil) + message.Add("warnMetricStatus", warnMetricStatus.ToJson()) + a.Publish(message) + } + + if err = a.notifyAdminsOfWarnMetricStatus(warnMetric.Id); err != nil { + mlog.Error("Failed to send notifications to admin users.", mlog.Err(err)) + } + + if warnMetric.IsRunOnce { + a.setWarnMetricsStatusForId(warnMetric.Id, model.WARN_METRIC_STATUS_RUNONCE) + } + } +} + func doLicenseExpirationCheck(a *App) { a.Srv().LoadLicense() license := a.Srv().License() diff --git a/i18n/en.json b/i18n/en.json index f334940ec1..3af8210b50 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1178,6 +1178,18 @@ "id": "api.create_terms_of_service.empty_text.app_error", "translation": "Please enter text for your Custom Terms of Service." }, + { + "id": "api.email.send_warn_metric_ack.failure.app_error", + "translation": "Failure to send admin acknowledgment email" + }, + { + "id": "api.email.send_warn_metric_ack.invalid_warn_metric.app_error", + "translation": "Could not find warn metric." + }, + { + "id": "api.email.send_warn_metric_ack.missing_server.app_error", + "translation": "SMTP Server is required" + }, { "id": "api.email_batching.add_notification_email_to_batch.channel_full.app_error", "translation": "Email batching job's receiving channel was full. Please increase the EmailBatchingBufferSize." @@ -1926,6 +1938,86 @@ "id": "api.server.start_server.starting.critical", "translation": "Error starting server, err:%v" }, + { + "id": "api.server.warn_metric.bot_response.mailto_contact_header", + "translation": "Contact: {{.Contact}}" + }, + { + "id": "api.server.warn_metric.bot_response.mailto_diagnostic_id_header", + "translation": "Diagnostic Id: {{.DiagnosticId}}" + }, + { + "id": "api.server.warn_metric.bot_response.mailto_email_header", + "translation": "Email: {{.Email}}" + }, + { + "id": "api.server.warn_metric.bot_response.mailto_footer", + "translation": "If you have any additional inquiries, please contact support@mattermost.com" + }, + { + "id": "api.server.warn_metric.bot_response.mailto_registered_users_header", + "translation": "Total Active Users: {{.NoRegisteredUsers}}" + }, + { + "id": "api.server.warn_metric.bot_response.mailto_site_url_header", + "translation": "Site URL: {{.SiteUrl}}" + }, + { + "id": "api.server.warn_metric.bot_response.mailto_subject", + "translation": "Mattermost Contact Us request" + }, + { + "id": "api.server.warn_metric.bot_response.notification_failure.body", + "translation": "Please email us." + }, + { + "id": "api.server.warn_metric.bot_response.notification_failure.message", + "translation": "Message could not be sent." + }, + { + "id": "api.server.warn_metric.bot_response.notification_success.message", + "translation": "Thank you for contacting Mattermost. We will follow up with you soon." + }, + { + "id": "api.server.warn_metric.bot_response.number_of_users.mailto_body", + "translation": "Mattermost Contact Us request. My team has now {{.Limit}} users and I am considering Mattermost Enterprise Edition.\r\n" + }, + { + "id": "api.server.warn_metric.contact_us", + "translation": "Contact us" + }, + { + "id": "api.server.warn_metric.contacting_us", + "translation": "Contacting us" + }, + { + "id": "api.server.warn_metric.email_us", + "translation": "Email us" + }, + { + "id": "api.server.warn_metric.number_of_active_users_200.notification_body", + "translation": "Your Mattermost system now has 200 users. As your user base grows, provisioning new accounts can become time-consuming. We recommend that you upgrade to Mattermost Enterprise E10 and integrate your organization’s Active Directory/LDAP, which will allow anyone with an account to access Mattermost. Users can log in without having to create new usernames and passwords, and administrators save time provisioning and managing accounts.\n[Learn more about integrating with AD/LDAP](https://docs.mattermost.com/deployment/sso-ldap.html?utm_medium=product&utm_source=mattermost-advisor-bot&utm_content=adldap)" + }, + { + "id": "api.server.warn_metric.number_of_active_users_200.notification_title", + "translation": "Integrate AD/LDAP" + }, + { + "id": "api.server.warn_metric.number_of_active_users_400.notification_body", + "translation": "Your Mattermost system now has 400 users. When you connect Mattermost with your organization's single sign-on provider, users can access Mattermost without having to re-enter their credentials. Contact support to learn more about integrating with SAML 2.0, available in Mattermost Enterprise E20.\n[Learn more about integrating with SAML 2.0](https://docs.mattermost.com/deployment/sso-saml.html?utm_medium=product&utm_source=mattermost-advisor-bot&utm_content=saml)" + }, + { + "id": "api.server.warn_metric.number_of_active_users_400.notification_title", + "translation": "Integrate SAML 2.0" + }, + { + "id": "api.server.warn_metric.number_of_active_users_500.notification_body", + "translation": "Mattermost strongly recommends that deployments of over 500 users upgrade to Mattermost Enterprise E20, which offers features such as user management, server clustering, and performance monitoring." + }, + { + "id": "api.server.warn_metric.number_of_active_users_500.notification_title", + "translation": "Upgrade to Mattermost Enterprise edition" + }, { "id": "api.slackimport.slack_add_bot_user.email_pwd", "translation": "The Integration/Slack Bot user with email {{.Email}} and password {{.Password}} has been imported.\r\n" @@ -2462,6 +2554,38 @@ "id": "api.templates.verify_subject", "translation": "[{{ .SiteName }}] Email Verification" }, + { + "id": "api.templates.warn_metric_ack.body.contact_email_header", + "translation": "Email: " + }, + { + "id": "api.templates.warn_metric_ack.body.contact_name_header", + "translation": "Contact: " + }, + { + "id": "api.templates.warn_metric_ack.body.diagnostic_id_header", + "translation": "Diagnostic Id: " + }, + { + "id": "api.templates.warn_metric_ack.body.registered_users_header", + "translation": "Total Active Users: " + }, + { + "id": "api.templates.warn_metric_ack.body.site_url_header", + "translation": "Site URL: " + }, + { + "id": "api.templates.warn_metric_ack.footer", + "translation": "If you have any additional inquiries, please contact support@mattermost.com" + }, + { + "id": "api.templates.warn_metric_ack.number_of_active_users.body", + "translation": "Mattermost Contact Us request. My team has now {{ .Limit }} users and I am considering Mattermost Enterprise Edition." + }, + { + "id": "api.templates.warn_metric_ack.subject", + "translation": "Mattermost Contact Us request" + }, { "id": "api.templates.welcome_body.app_download_info", "translation": "For the best experience, download the apps for PC, Mac, iOS and Android from:" @@ -4202,6 +4326,26 @@ "id": "app.submit_interactive_dialog.json_error", "translation": "Encountered an error encoding JSON for the interactive dialog." }, + { + "id": "app.system.warn_metric.bot_description", + "translation": "[Learn more about the Mattermost Advisor](https://about.mattermost.com/default-channel-handle-documentation)" + }, + { + "id": "app.system.warn_metric.bot_displayname", + "translation": "Mattermost Advisor" + }, + { + "id": "app.system.warn_metric.notification.empty_admin_list.app_error", + "translation": "List of admins is empty." + }, + { + "id": "app.system.warn_metric.notification.invalid_metric.app_error", + "translation": "Could not find metric." + }, + { + "id": "app.system.warn_metric.store.app_error", + "translation": "Failed to store value for {{.WarnMetricName}}" + }, { "id": "app.system_install_date.parse_int.app_error", "translation": "Failed to parse installation date." diff --git a/model/bot.go b/model/bot.go index 15ef6a70ca..fb46be495c 100644 --- a/model/bot.go +++ b/model/bot.go @@ -13,9 +13,10 @@ import ( ) const ( - BOT_DISPLAY_NAME_MAX_RUNES = USER_FIRST_NAME_MAX_RUNES - BOT_DESCRIPTION_MAX_RUNES = 1024 - BOT_CREATOR_ID_MAX_RUNES = KEY_VALUE_PLUGIN_ID_MAX_RUNES // UserId or PluginId + BOT_DISPLAY_NAME_MAX_RUNES = USER_FIRST_NAME_MAX_RUNES + BOT_DESCRIPTION_MAX_RUNES = 1024 + BOT_CREATOR_ID_MAX_RUNES = KEY_VALUE_PLUGIN_ID_MAX_RUNES // UserId or PluginId + BOT_WARN_METRIC_BOT_USERNAME = "mattermost-advisor" ) // Bot is a special type of User meant for programmatic interactions. diff --git a/model/config.go b/model/config.go index 12419ab071..6c5776a9dc 100644 --- a/model/config.go +++ b/model/config.go @@ -47,6 +47,7 @@ const ( GENERIC_NO_CHANNEL_NOTIFICATION = "generic_no_channel" GENERIC_NOTIFICATION = "generic" GENERIC_NOTIFICATION_SERVER = "https://push-test.mattermost.com" + MM_SUPPORT_ADDRESS = "support@mattermost.com" FULL_NOTIFICATION = "full" ID_LOADED_NOTIFICATION = "id_loaded" diff --git a/model/post.go b/model/post.go index 852a504f9c..7c27eca9b6 100644 --- a/model/post.go +++ b/model/post.go @@ -64,6 +64,7 @@ const ( POST_PROPS_MENTION_HIGHLIGHT_DISABLED = "mentionHighlightDisabled" POST_PROPS_GROUP_HIGHLIGHT_DISABLED = "disable_group_highlight" + POST_SYSTEM_WARN_METRIC_STATUS = "warn_metric_status" ) var AT_MENTION_PATTEN = regexp.MustCompile(`\B@`) @@ -312,7 +313,8 @@ func (o *Post) IsValid(maxPostSize int) *AppError { POST_CHANNEL_RESTORED, POST_CHANGE_CHANNEL_PRIVACY, POST_ME, - POST_ADD_BOT_TEAMS_CHANNELS: + POST_ADD_BOT_TEAMS_CHANNELS, + POST_SYSTEM_WARN_METRIC_STATUS: default: if !strings.HasPrefix(o.Type, POST_CUSTOM_TYPE_PREFIX) { return NewAppError("Post.IsValid", "model.post.is_valid.type.app_error", nil, "id="+o.Type, http.StatusBadRequest) diff --git a/model/system.go b/model/system.go index b3100c92db..1ad0775c93 100644 --- a/model/system.go +++ b/model/system.go @@ -10,16 +10,26 @@ import ( ) const ( - SYSTEM_DIAGNOSTIC_ID = "DiagnosticId" - SYSTEM_RAN_UNIT_TESTS = "RanUnitTests" - SYSTEM_LAST_SECURITY_TIME = "LastSecurityTime" - SYSTEM_ACTIVE_LICENSE_ID = "ActiveLicenseId" - SYSTEM_LAST_COMPLIANCE_TIME = "LastComplianceTime" - SYSTEM_ASYMMETRIC_SIGNING_KEY = "AsymmetricSigningKey" - SYSTEM_POST_ACTION_COOKIE_SECRET = "PostActionCookieSecret" - SYSTEM_INSTALLATION_DATE_KEY = "InstallationDate" - SYSTEM_FIRST_SERVER_RUN_TIMESTAMP_KEY = "FirstServerRunTimestamp" - SYSTEM_CLUSTER_ENCRYPTION_KEY = "ClusterEncryptionKey" + SYSTEM_DIAGNOSTIC_ID = "DiagnosticId" + SYSTEM_RAN_UNIT_TESTS = "RanUnitTests" + SYSTEM_LAST_SECURITY_TIME = "LastSecurityTime" + SYSTEM_ACTIVE_LICENSE_ID = "ActiveLicenseId" + SYSTEM_LAST_COMPLIANCE_TIME = "LastComplianceTime" + SYSTEM_ASYMMETRIC_SIGNING_KEY = "AsymmetricSigningKey" + SYSTEM_POST_ACTION_COOKIE_SECRET = "PostActionCookieSecret" + SYSTEM_INSTALLATION_DATE_KEY = "InstallationDate" + SYSTEM_FIRST_SERVER_RUN_TIMESTAMP_KEY = "FirstServerRunTimestamp" + SYSTEM_CLUSTER_ENCRYPTION_KEY = "ClusterEncryptionKey" + SYSTEM_WARN_METRIC_NUMBER_OF_ACTIVE_USERS_200 = "warn_metric_number_of_active_users_200" + SYSTEM_WARN_METRIC_NUMBER_OF_ACTIVE_USERS_400 = "warn_metric_number_of_active_users_400" + SYSTEM_WARN_METRIC_NUMBER_OF_ACTIVE_USERS_500 = "warn_metric_number_of_active_users_500" +) + +const ( + WARN_METRIC_STATUS_LIMIT_REACHED = "true" + WARN_METRIC_STATUS_RUNONCE = "runonce" + WARN_METRIC_STATUS_ACK = "ack" + WARN_METRIC_STATUS_STORE_PREFIX = "warn_metric_" ) type System struct { @@ -70,3 +80,78 @@ func ServerBusyStateFromJson(r io.Reader) *ServerBusyState { json.NewDecoder(r).Decode(&sbs) return sbs } + +var WarnMetricsTable = map[string]WarnMetric{ + SYSTEM_WARN_METRIC_NUMBER_OF_ACTIVE_USERS_200: { + Id: SYSTEM_WARN_METRIC_NUMBER_OF_ACTIVE_USERS_200, + Limit: 200, + IsBotOnly: true, + IsRunOnce: true, + }, + SYSTEM_WARN_METRIC_NUMBER_OF_ACTIVE_USERS_400: { + Id: SYSTEM_WARN_METRIC_NUMBER_OF_ACTIVE_USERS_400, + Limit: 400, + IsBotOnly: true, + IsRunOnce: true, + }, + SYSTEM_WARN_METRIC_NUMBER_OF_ACTIVE_USERS_500: { + Id: SYSTEM_WARN_METRIC_NUMBER_OF_ACTIVE_USERS_500, + Limit: 500, + IsBotOnly: false, + IsRunOnce: false, + }, +} + +type WarnMetric struct { + Id string + Limit int64 + IsBotOnly bool + IsRunOnce bool +} + +type WarnMetricDisplayTexts struct { + BotTitle string + BotMessageBody string + BotMailToBody string + EmailBody string +} +type WarnMetricStatus struct { + Id string `json:"id"` + Limit int64 `json:"limit"` + Acked bool `json:"acked"` + StoreStatus string `json:"store_status,omitempty"` +} + +func (wms *WarnMetricStatus) ToJson() string { + b, _ := json.Marshal(wms) + return string(b) +} + +func WarnMetricStatusFromJson(data io.Reader) *WarnMetricStatus { + var o WarnMetricStatus + if err := json.NewDecoder(data).Decode(&o); err != nil { + return nil + } else { + return &o + } +} + +func MapWarnMetricStatusToJson(o map[string]*WarnMetricStatus) string { + b, _ := json.Marshal(o) + return string(b) +} + +type SendWarnMetricAck struct { + ForceAck bool `json:"forceAck"` +} + +func (swma *SendWarnMetricAck) ToJson() string { + b, _ := json.Marshal(swma) + return string(b) +} + +func SendWarnMetricAckFromJson(r io.Reader) *SendWarnMetricAck { + var swma *SendWarnMetricAck + json.NewDecoder(r).Decode(&swma) + return swma +} diff --git a/model/websocket_message.go b/model/websocket_message.go index 0fd05ef3dd..281b50cff2 100644 --- a/model/websocket_message.go +++ b/model/websocket_message.go @@ -66,6 +66,8 @@ const ( WEBSOCKET_EVENT_SIDEBAR_CATEGORY_UPDATED = "sidebar_category_updated" WEBSOCKET_EVENT_SIDEBAR_CATEGORY_DELETED = "sidebar_category_deleted" WEBSOCKET_EVENT_SIDEBAR_CATEGORY_ORDER_UPDATED = "sidebar_category_order_updated" + WEBSOCKET_WARN_METRIC_STATUS_RECEIVED = "warn_metric_status_received" + WEBSOCKET_WARN_METRIC_STATUS_REMOVED = "warn_metric_status_removed" ) type WebSocketMessage interface { diff --git a/services/mailservice/mail.go b/services/mailservice/mail.go index 3e392cb759..33fde79e7c 100644 --- a/services/mailservice/mail.go +++ b/services/mailservice/mail.go @@ -29,6 +29,7 @@ type mailData struct { mimeTo string smtpTo string from mail.Address + cc string replyTo mail.Address subject string htmlBody string @@ -250,7 +251,7 @@ func TestConnection(config *model.Config) *model.AppError { return nil } -func SendMailWithEmbeddedFilesUsingConfig(to, subject, htmlBody string, embeddedFiles map[string]io.Reader, config *model.Config, enableComplianceFeatures bool) *model.AppError { +func SendMailWithEmbeddedFilesUsingConfig(to, subject, htmlBody string, embeddedFiles map[string]io.Reader, config *model.Config, enableComplianceFeatures bool, ccMail string) *model.AppError { fromMail := mail.Address{Name: *config.EmailSettings.FeedbackName, Address: *config.EmailSettings.FeedbackEmail} replyTo := mail.Address{Name: *config.EmailSettings.FeedbackName, Address: *config.EmailSettings.ReplyToAddress} @@ -258,6 +259,7 @@ func SendMailWithEmbeddedFilesUsingConfig(to, subject, htmlBody string, embedded mimeTo: to, smtpTo: to, from: fromMail, + cc: ccMail, replyTo: replyTo, subject: subject, htmlBody: htmlBody, @@ -267,8 +269,8 @@ func SendMailWithEmbeddedFilesUsingConfig(to, subject, htmlBody string, embedded return sendMailUsingConfigAdvanced(mail, config, enableComplianceFeatures) } -func SendMailUsingConfig(to, subject, htmlBody string, config *model.Config, enableComplianceFeatures bool) *model.AppError { - return SendMailWithEmbeddedFilesUsingConfig(to, subject, htmlBody, nil, config, enableComplianceFeatures) +func SendMailUsingConfig(to, subject, htmlBody string, config *model.Config, enableComplianceFeatures bool, ccMail string) *model.AppError { + return SendMailWithEmbeddedFilesUsingConfig(to, subject, htmlBody, nil, config, enableComplianceFeatures, ccMail) } // allows for sending an email with attachments and differing MIME/SMTP recipients @@ -328,6 +330,10 @@ func SendMail(c smtpClient, mail mailData, fileBackend filesstore.FileBackend, d headers["Reply-To"] = []string{mail.replyTo.String()} } + if len(mail.cc) > 0 { + headers["CC"] = []string{mail.cc} + } + for k, v := range mail.mimeHeaders { headers[k] = []string{encodeRFC2047Word(v)} } diff --git a/services/mailservice/mail_test.go b/services/mailservice/mail_test.go index 5bbe8c565e..cffe819a25 100644 --- a/services/mailservice/mail_test.go +++ b/services/mailservice/mail_test.go @@ -138,11 +138,12 @@ func TestSendMailUsingConfig(t *testing.T) { var emailTo = "test@example.com" var emailSubject = "Testing this email" var emailBody = "This is a test from autobot" + var emailCC = "test@example.com" //Delete all the messages before check the sample email DeleteMailBox(emailTo) - err2 := SendMailUsingConfig(emailTo, emailSubject, emailBody, cfg, true) + err2 := SendMailUsingConfig(emailTo, emailSubject, emailBody, cfg, true, emailCC) require.Nil(t, err2, "Should connect to the SMTP Server") //Check if the email was send to the right email address @@ -176,6 +177,7 @@ func TestSendMailWithEmbeddedFilesUsingConfig(t *testing.T) { var emailTo = "test@example.com" var emailSubject = "Testing this email" var emailBody = "This is a test from autobot" + var emailCC = "test@example.com" //Delete all the messages before check the sample email DeleteMailBox(emailTo) @@ -184,7 +186,7 @@ func TestSendMailWithEmbeddedFilesUsingConfig(t *testing.T) { "test1.png": bytes.NewReader([]byte("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx")), "test2.png": bytes.NewReader([]byte("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx")), } - err2 := SendMailWithEmbeddedFilesUsingConfig(emailTo, emailSubject, emailBody, embeddedFiles, cfg, true) + err2 := SendMailWithEmbeddedFilesUsingConfig(emailTo, emailSubject, emailBody, embeddedFiles, cfg, true, emailCC) require.Nil(t, err2, "Should connect to the SMTP Server") //Check if the email was send to the right email address @@ -416,7 +418,7 @@ func TestSendMail(t *testing.T) { for testName, tc := range testCases { t.Run(testName, func(t *testing.T) { - mail := mailData{"", "", mail.Address{}, tc.replyTo, "", "", nil, nil, nil} + mail := mailData{"", "", mail.Address{}, "", tc.replyTo, "", "", nil, nil, nil} appErr = SendMail(mocm, mail, mockBackend, time.Now()) require.Nil(t, appErr) if len(tc.contains) > 0 { diff --git a/templates/warn_metric_ack.html b/templates/warn_metric_ack.html new file mode 100644 index 0000000000..0365b58612 --- /dev/null +++ b/templates/warn_metric_ack.html @@ -0,0 +1,84 @@ +{{define "warn_metric_ack"}} + + + + + +
+ + + + + + {{template "email_footer" . }} + +
+ + + + + + + +
+

{{.Props.Title}}

+

{{.Props.Info}}
{{.Props.Warning}}

+
+ + + + + + + {{if .Props.ContactEmailValue}} + + + + {{end}} + {{if .Props.RegisteredUsersValue}} + + + + {{end}} + {{if .Props.SiteURL}} + + + + {{end}} + {{if .Props.DiagnosticIdValue}} + + + + {{end}} +
+

+ {{.Props.ContactNameHeader}} + {{.Props.ContactNameValue}}

+
+

+ {{.Props.ContactEmailHeader}} + {{.Props.ContactEmailValue}}

+
+

+ {{.Props.RegisteredUsersHeader}} + {{.Props.RegisteredUsersValue}}

+
+

{{.Props.SiteURLHeader}} + {{.Props.SiteURL}}

+
+

+ {{.Props.DiagnosticIdHeader}} + {{.Props.DiagnosticIdValue}}

+
+
+
+
+ + + + +{{end}} diff --git a/web/params.go b/web/params.go index b9690a06fe..ad4f12234d 100644 --- a/web/params.go +++ b/web/params.go @@ -78,6 +78,7 @@ type Params struct { FilterAllowReference bool FilterParentTeamPermitted bool CategoryId string + WarnMetricId string } func ParamsFromRequest(r *http.Request) *Params { @@ -316,5 +317,9 @@ func ParamsFromRequest(r *http.Request) *Params { params.IncludeDeleted = val } + if val, ok := props["warn_metric_id"]; ok { + params.WarnMetricId = val + } + return params }