From 8d6d1c51c2f0ce2bf7b4a0af35504a2c90eb485e Mon Sep 17 00:00:00 2001 From: Allan Guwatudde Date: Tue, 22 Feb 2022 20:41:58 +0300 Subject: [PATCH] [MM-40917] - Inactive Server Email Notification (#19374) * [MM-40917] - Inactive Server Email Notification * add email template * make store layers * add some store tests * fix translations * fix logic * improve * fix lint * feedback-impl * fix wrong text * optimize queries * move feature flag check * feedback impl-1 * add line * feedback impl Co-authored-by: Mattermod --- app/email/email.go | 34 ++ app/server.go | 7 + app/server_inactivity.go | 141 ++++++ i18n/en.json | 46 +- model/feature_flags.go | 3 + store/opentracinglayer/opentracinglayer.go | 36 ++ store/retrylayer/retrylayer.go | 42 ++ store/sqlstore/post_store.go | 11 + store/sqlstore/session_store.go | 11 + store/store.go | 2 + store/storetest/mocks/PostStore.go | 21 + store/storetest/mocks/SessionStore.go | 21 + store/storetest/post_store.go | 26 + store/storetest/session_store.go | 18 + store/timerlayer/timerlayer.go | 32 ++ templates/inactivity_body.html | 526 +++++++++++++++++++++ templates/inactivity_body.mjml | 62 +++ 17 files changed, 1038 insertions(+), 1 deletion(-) create mode 100644 app/server_inactivity.go create mode 100644 templates/inactivity_body.html create mode 100644 templates/inactivity_body.mjml diff --git a/app/email/email.go b/app/email/email.go index 03874c364c..4d368a752d 100644 --- a/app/email/email.go +++ b/app/email/email.go @@ -762,6 +762,39 @@ func (es *Service) SendAtUserLimitWarningEmail(email string, locale string, site return true, nil } +func (es *Service) SendLicenseInactivityEmail(email, name, locale, siteURL string) error { + T := i18n.GetUserTranslations(locale) + subject := T("api.templates.server_inactivity_subject") + data := es.NewEmailTemplateData(locale) + data.Props["SiteURL"] = siteURL + data.Props["Title"] = T("api.templates.server_inactivity_title") + data.Props["SubTitle"] = T("api.templates.server_inactivity_subtitle", map[string]interface{}{"Name": name}) + data.Props["InfoBullet"] = T("api.templates.server_inactivity_info_bullet") + data.Props["InfoBullet1"] = T("api.templates.server_inactivity_info_bullet1") + data.Props["InfoBullet2"] = T("api.templates.server_inactivity_info_bullet2") + data.Props["Info"] = T("api.templates.server_inactivity_info") + data.Props["EmailUs"] = T("api.templates.email_us_anytime_at") + data.Props["QuestionTitle"] = T("api.templates.questions_footer.title") + data.Props["QuestionInfo"] = T("api.templates.questions_footer.info") + data.Props["Button"] = T("api.templates.server_inactivity_button") + data.Props["SupportEmail"] = "feedback@mattermost.com" + data.Props["ButtonURL"] = siteURL + data.Props["Channels"] = T("Channels") + data.Props["Playbooks"] = T("Playbooks") + data.Props["Boards"] = T("Boards") + + body, err := es.templatesContainer.RenderToString("inactivity_body", data) + if err != nil { + return err + } + + if err := es.sendMail(email, subject, body); err != nil { + return err + } + + return nil +} + func (es *Service) SendLicenseUpForRenewalEmail(email, name, locale, siteURL, renewalLink string, daysToExpiration int) error { T := i18n.GetUserTranslations(locale) subject := T("api.templates.license_up_for_renewal_subject") @@ -775,6 +808,7 @@ func (es *Service) SendLicenseUpForRenewalEmail(email, name, locale, siteURL, re data.Props["Button"] = T("api.templates.license_up_for_renewal_renew_now") data.Props["ButtonURL"] = renewalLink data.Props["QuestionTitle"] = T("api.templates.questions_footer.title") + data.Props["SupportEmail"] = "feedback@mattermost.com" data.Props["QuestionInfo"] = T("api.templates.questions_footer.info") body, err := es.templatesContainer.RenderToString("license_up_for_renewal", data) diff --git a/app/server.go b/app/server.go index 6acbf26f15..1ea0a6b914 100644 --- a/app/server.go +++ b/app/server.go @@ -633,6 +633,7 @@ func NewServer(options ...Option) (*Server, error) { s.Go(func() { appInstance := New(ServerConnector(s.Channels())) s.runLicenseExpirationCheckJob() + s.runInactivityCheckJob() runDNDStatusExpireJob(appInstance) }) s.runJobs() @@ -1478,6 +1479,12 @@ func runJobsCleanupJob(s *Server) { }, time.Hour*24) } +func (s *Server) runInactivityCheckJob() { + model.CreateRecurringTask("Server inactivity Check", func() { + s.doInactivityCheck() + }, time.Hour*24) +} + func (s *Server) runLicenseExpirationCheckJob() { s.doLicenseExpirationCheck() model.CreateRecurringTask("License Expiration Check", func() { diff --git a/app/server_inactivity.go b/app/server_inactivity.go new file mode 100644 index 0000000000..363c5aab0e --- /dev/null +++ b/app/server_inactivity.go @@ -0,0 +1,141 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "fmt" + "os" + "strconv" + "time" + + "github.com/mattermost/mattermost-server/v6/model" + "github.com/mattermost/mattermost-server/v6/shared/mlog" + "github.com/mattermost/mattermost-server/v6/store" +) + +const serverInactivityHours = 100 + +func (s *Server) doInactivityCheck() { + if !s.Config().FeatureFlags.EnableInactivityCheckJob { + mlog.Info("No activity check because EnableInactivityCheckJob feature flag is disabled") + return + } + + inactivityDurationHourseEnv := os.Getenv("MM_INACTIVITY_DURATION") + inactivityDurationHours, parseError := strconv.ParseFloat(inactivityDurationHourseEnv, 64) + if parseError != nil { + // default to 100 hours + inactivityDurationHours = serverInactivityHours + } + + systemValue, sysValErr := s.Store.System().GetByName("INACTIVITY") + if sysValErr != nil { + // any other error apart from ErrNotFound we stop execution + if _, ok := sysValErr.(*store.ErrNotFound); !ok { + mlog.Warn("An error occurred while getting INACTIVITY from system store", mlog.Err(sysValErr)) + return + } + } + + // If we have a system value, it means this job already ran atleast once. + // we then check the last time the job ran plus the last time a post was made to determine if we + // can remind the user to use workspace again. If no post was made, we check the last time they logged in (session) + // and determine whether to send them a reminder. + if systemValue != nil { + sysT, _ := strconv.ParseInt(systemValue.Value, 10, 64) + tt := time.Unix(sysT/1000, 0) + timeLastSentInativityEmail := time.Since(tt).Hours() + + lastPostAt, _ := s.Store.Post().GetLastPostRowCreateAt() + if lastPostAt != 0 { + posT := time.Unix(lastPostAt/1000, 0) + timeForLastPost := time.Since(posT).Hours() + + if timeLastSentInativityEmail > inactivityDurationHours && timeForLastPost > inactivityDurationHours { + s.takeInactivityAction() + } + return + } + + lastSessionAt, _ := s.Store.Session().GetLastSessionRowCreateAt() + if lastSessionAt != 0 { + sesT := time.Unix(lastSessionAt/1000, 0) + timeForLastSession := time.Since(sesT).Hours() + + if timeLastSentInativityEmail > inactivityDurationHours && timeForLastSession > inactivityDurationHours { + s.takeInactivityAction() + } + return + } + } + + // The first time this job runs. We check if the user has not made any posts + // and remind them to use the workspace. If no posts have been made. We check the last time + // they logged in (session) and send a reminder. + + lastPostAt, _ := s.Store.Post().GetLastPostRowCreateAt() + if lastPostAt != 0 { + posT := time.Unix(lastPostAt/1000, 0) + timeForLastPost := time.Since(posT).Hours() + if timeForLastPost > inactivityDurationHours { + s.takeInactivityAction() + } + return + } + + lastSessionAt, _ := s.Store.Session().GetLastSessionRowCreateAt() + if lastSessionAt != 0 { + sesT := time.Unix(lastSessionAt/1000, 0) + timeForLastSession := time.Since(sesT).Hours() + if timeForLastSession > inactivityDurationHours { + s.takeInactivityAction() + } + return + } +} + +func (s *Server) takeInactivityAction() { + siteURL := *s.Config().ServiceSettings.SiteURL + if siteURL == "" { + mlog.Warn("No SiteURL configured") + } + + properties := map[string]interface{}{ + "SiteURL": siteURL, + } + s.GetTelemetryService().SendTelemetry("inactive_server", properties) + users, err := s.Store.User().GetSystemAdminProfiles() + if err != nil { + mlog.Error("Failed to get system admins for inactivity check from Mattermost.") + return + } + + for _, user := range users { + if user.Email == "" { + mlog.Error("Invalid system admin email.", mlog.String("user_email", user.Email)) + continue + } + + name := user.FirstName + if name == "" { + name = user.Username + } + + mlog.Debug("Sending inactivity reminder email.", mlog.String("user_email", user.Email)) + s.Go(func() { + if err := s.EmailService.SendLicenseInactivityEmail(user.Email, name, user.Locale, siteURL); err != nil { + mlog.Error("Error while sending inactivity reminder email.", mlog.String("user_email", user.Email), mlog.Err(err)) + } + }) + } + + // Mark time that we sent emails. The next time we calculate + sysVar := &model.System{Name: "INACTIVITY", Value: fmt.Sprint(model.GetMillis())} + if err := s.Store.System().SaveOrUpdate(sysVar); err != nil { + mlog.Error("Unable to save INACTIVITY", mlog.Err(err)) + } + + // do some telemetry about sending the email + s.GetTelemetryService().SendTelemetry("inactive_server_emails_sent", properties) +} diff --git a/i18n/en.json b/i18n/en.json index 8a0b58b948..a0be67dcf8 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -7,6 +7,14 @@ "id": "August", "translation": "August" }, + { + "id": "Boards", + "translation": "Boards" + }, + { + "id": "Channels", + "translation": "Channels" + }, { "id": "December", "translation": "December" @@ -43,6 +51,10 @@ "id": "October", "translation": "October" }, + { + "id": "Playbooks", + "translation": "Playbooks" + }, { "id": "September", "translation": "September" @@ -3245,7 +3257,7 @@ }, { "id": "api.templates.email_footer_v2", - "translation": "© 2021 Mattermost, Inc. 530 Lytton Avenue, Second floor, Palo Alto, CA, 94301" + "translation": "© 2022 Mattermost, Inc. 530 Lytton Avenue, Second floor, Palo Alto, CA, 94301" }, { "id": "api.templates.email_info1", @@ -3563,6 +3575,38 @@ "id": "api.templates.reset_subject", "translation": "[{{ .SiteName }}] Reset your password" }, + { + "id": "api.templates.server_inactivity_button", + "translation": "Open Mattermost" + }, + { + "id": "api.templates.server_inactivity_info", + "translation": "Come and check it out!" + }, + { + "id": "api.templates.server_inactivity_info_bullet", + "translation": "Guest Access to specified " + }, + { + "id": "api.templates.server_inactivity_info_bullet1", + "translation": "Workflow management with " + }, + { + "id": "api.templates.server_inactivity_info_bullet2", + "translation": "Manage tasks using " + }, + { + "id": "api.templates.server_inactivity_subject", + "translation": "HEY! Open Mattermost to increase your team’s productivity!" + }, + { + "id": "api.templates.server_inactivity_subtitle", + "translation": "Hey {{.Name}}, we’ve noticed that your Mattermost server is collecting a bit of dust. Take a look at some features that can help lighten your team's workload." + }, + { + "id": "api.templates.server_inactivity_title", + "translation": "Unlock increased productivity with these awesome features" + }, { "id": "api.templates.signin_change_email.body.info", "translation": "You updated your sign-in method on {{ .SiteName }} to {{.Method}}." diff --git a/model/feature_flags.go b/model/feature_flags.go index 211d73b6bd..1ebf47a196 100644 --- a/model/feature_flags.go +++ b/model/feature_flags.go @@ -72,6 +72,8 @@ type FeatureFlags struct { NormalizeLdapDNs bool + EnableInactivityCheckJob bool + // Enable special onboarding flow for first admin UseCaseOnboarding bool @@ -105,6 +107,7 @@ func (f *FeatureFlags) SetDefaults() { f.InlinePostEditing = false f.BoardsDataRetention = false f.NormalizeLdapDNs = false + f.EnableInactivityCheckJob = true f.UseCaseOnboarding = false f.WorkspaceOptimizationDashboard = false f.GraphQL = false diff --git a/store/opentracinglayer/opentracinglayer.go b/store/opentracinglayer/opentracinglayer.go index fdda9aade5..ad7f352f2a 100644 --- a/store/opentracinglayer/opentracinglayer.go +++ b/store/opentracinglayer/opentracinglayer.go @@ -5489,6 +5489,24 @@ func (s *OpenTracingLayerPostStore) GetFlaggedPostsForTeam(userID string, teamID return result, err } +func (s *OpenTracingLayerPostStore) GetLastPostRowCreateAt() (int64, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "PostStore.GetLastPostRowCreateAt") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.PostStore.GetLastPostRowCreateAt() + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + func (s *OpenTracingLayerPostStore) GetMaxPostSize() int { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "PostStore.GetMaxPostSize") @@ -7261,6 +7279,24 @@ func (s *OpenTracingLayerSessionStore) Get(ctx context.Context, sessionIDOrToken return result, err } +func (s *OpenTracingLayerSessionStore) GetLastSessionRowCreateAt() (int64, error) { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "SessionStore.GetLastSessionRowCreateAt") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + result, err := s.SessionStore.GetLastSessionRowCreateAt() + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return result, err +} + func (s *OpenTracingLayerSessionStore) GetSessions(userID string) ([]*model.Session, error) { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "SessionStore.GetSessions") diff --git a/store/retrylayer/retrylayer.go b/store/retrylayer/retrylayer.go index 3a7e1ea454..db408ce154 100644 --- a/store/retrylayer/retrylayer.go +++ b/store/retrylayer/retrylayer.go @@ -6211,6 +6211,27 @@ func (s *RetryLayerPostStore) GetFlaggedPostsForTeam(userID string, teamID strin } +func (s *RetryLayerPostStore) GetLastPostRowCreateAt() (int64, error) { + + tries := 0 + for { + result, err := s.PostStore.GetLastPostRowCreateAt() + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerPostStore) GetMaxPostSize() int { return s.PostStore.GetMaxPostSize() @@ -8260,6 +8281,27 @@ func (s *RetryLayerSessionStore) Get(ctx context.Context, sessionIDOrToken strin } +func (s *RetryLayerSessionStore) GetLastSessionRowCreateAt() (int64, error) { + + tries := 0 + for { + result, err := s.SessionStore.GetLastSessionRowCreateAt() + if err == nil { + return result, nil + } + if !isRepeatableError(err) { + return result, err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return result, err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerSessionStore) GetSessions(userID string) ([]*model.Session, error) { tries := 0 diff --git a/store/sqlstore/post_store.go b/store/sqlstore/post_store.go index 8609b683bf..b005abb21b 100644 --- a/store/sqlstore/post_store.go +++ b/store/sqlstore/post_store.go @@ -2057,6 +2057,17 @@ func (s *SqlPostStore) AnalyticsPostCount(teamId string, mustHaveFile bool, must return v, nil } +func (s *SqlPostStore) GetLastPostRowCreateAt() (int64, error) { + query := `SELECT CREATEAT FROM Posts ORDER BY CREATEAT DESC LIMIT 1` + var createAt int64 + err := s.GetReplicaX().Get(&createAt, query) + if err != nil { + return 0, errors.Wrapf(err, "failed to get last post createat") + } + + return createAt, nil +} + func (s *SqlPostStore) GetPostsCreatedAt(channelId string, time int64) ([]*model.Post, error) { query := `SELECT * FROM Posts WHERE CreateAt = ? AND ChannelId = ?` diff --git a/store/sqlstore/session_store.go b/store/sqlstore/session_store.go index 94636706d8..c5cd86888a 100644 --- a/store/sqlstore/session_store.go +++ b/store/sqlstore/session_store.go @@ -217,6 +217,17 @@ func (me SqlSessionStore) UpdateExpiresAt(sessionId string, time int64) error { return nil } +func (me *SqlSessionStore) GetLastSessionRowCreateAt() (int64, error) { + query := `SELECT CREATEAT FROM Sessions ORDER BY CREATEAT DESC LIMIT 1` + var createAt int64 + err := me.GetReplicaX().Get(&createAt, query) + if err != nil { + return 0, errors.Wrapf(err, "failed to get last session creatat") + } + + return createAt, nil +} + func (me SqlSessionStore) UpdateLastActivityAt(sessionId string, time int64) error { _, err := me.GetMasterX().Exec("UPDATE Sessions SET LastActivityAt = ? WHERE Id = ?", time, sessionId) if err != nil { diff --git a/store/store.go b/store/store.go index f9ff4e5f9f..37c5a9b193 100644 --- a/store/store.go +++ b/store/store.go @@ -347,6 +347,7 @@ type PostStore interface { AnalyticsPostCount(teamID string, mustHaveFile bool, mustHaveHashtag bool) (int64, error) ClearCaches() InvalidateLastPostTimeCache(channelID string) + GetLastPostRowCreateAt() (int64, error) GetPostsCreatedAt(channelID string, time int64) ([]*model.Post, error) Overwrite(post *model.Post) (*model.Post, error) OverwriteMultiple(posts []*model.Post) ([]*model.Post, int, error) @@ -462,6 +463,7 @@ type SessionStore interface { Remove(sessionIDOrToken string) error RemoveAllSessions() error PermanentDeleteSessionsByUser(teamID string) error + GetLastSessionRowCreateAt() (int64, error) UpdateExpiresAt(sessionID string, time int64) error UpdateLastActivityAt(sessionID string, time int64) error UpdateRoles(userID string, roles string) (string, error) diff --git a/store/storetest/mocks/PostStore.go b/store/storetest/mocks/PostStore.go index 5b91492e82..abfc7ef07f 100644 --- a/store/storetest/mocks/PostStore.go +++ b/store/storetest/mocks/PostStore.go @@ -252,6 +252,27 @@ func (_m *PostStore) GetFlaggedPostsForTeam(userID string, teamID string, offset return r0, r1 } +// GetLastPostRowCreateAt provides a mock function with given fields: +func (_m *PostStore) GetLastPostRowCreateAt() (int64, error) { + ret := _m.Called() + + var r0 int64 + if rf, ok := ret.Get(0).(func() int64); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(int64) + } + + var r1 error + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetMaxPostSize provides a mock function with given fields: func (_m *PostStore) GetMaxPostSize() int { ret := _m.Called() diff --git a/store/storetest/mocks/SessionStore.go b/store/storetest/mocks/SessionStore.go index 4d1e7999a6..d107d52406 100644 --- a/store/storetest/mocks/SessionStore.go +++ b/store/storetest/mocks/SessionStore.go @@ -74,6 +74,27 @@ func (_m *SessionStore) Get(ctx context.Context, sessionIDOrToken string) (*mode return r0, r1 } +// GetLastSessionRowCreateAt provides a mock function with given fields: +func (_m *SessionStore) GetLastSessionRowCreateAt() (int64, error) { + ret := _m.Called() + + var r0 int64 + if rf, ok := ret.Get(0).(func() int64); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(int64) + } + + var r1 error + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetSessions provides a mock function with given fields: userID func (_m *SessionStore) GetSessions(userID string) ([]*model.Session, error) { ret := _m.Called(userID) diff --git a/store/storetest/post_store.go b/store/storetest/post_store.go index 98bc053bb6..bb2f32e4fc 100644 --- a/store/storetest/post_store.go +++ b/store/storetest/post_store.go @@ -43,6 +43,7 @@ func TestPostStore(t *testing.T, ss store.Store, s SqlStore) { t.Run("GetFlaggedPosts", func(t *testing.T) { testPostStoreGetFlaggedPosts(t, ss) }) t.Run("GetFlaggedPostsForChannel", func(t *testing.T) { testPostStoreGetFlaggedPostsForChannel(t, ss) }) t.Run("GetPostsCreatedAt", func(t *testing.T) { testPostStoreGetPostsCreatedAt(t, ss) }) + t.Run("GetLastPostRowCreateAt", func(t *testing.T) { testPostStoreGetLastPostRowCreateAt(t, ss) }) t.Run("Overwrite", func(t *testing.T) { testPostStoreOverwrite(t, ss) }) t.Run("OverwriteMultiple", func(t *testing.T) { testPostStoreOverwriteMultiple(t, ss) }) t.Run("GetPostsByIds", func(t *testing.T) { testPostStoreGetPostsByIds(t, ss) }) @@ -2484,6 +2485,31 @@ func testPostStoreGetFlaggedPostsForChannel(t *testing.T, ss store.Store) { require.Len(t, r.Order, 0, "should have 0 posts") } +func testPostStoreGetLastPostRowCreateAt(t *testing.T, ss store.Store) { + createTime1 := model.GetMillis() + 1 + o0 := &model.Post{} + o0.ChannelId = model.NewId() + o0.UserId = model.NewId() + o0.Message = NewTestId() + o0.CreateAt = createTime1 + o0, err := ss.Post().Save(o0) + require.NoError(t, err) + + createTime2 := model.GetMillis() + 2 + + o1 := &model.Post{} + o1.ChannelId = o0.ChannelId + o1.UserId = model.NewId() + o1.Message = "Latest message" + o1.CreateAt = createTime2 + _, err = ss.Post().Save(o1) + require.NoError(t, err) + + createAt, err := ss.Post().GetLastPostRowCreateAt() + require.NoError(t, err) + assert.Equal(t, createAt, createTime2) +} + func testPostStoreGetPostsCreatedAt(t *testing.T, ss store.Store) { createTime := model.GetMillis() + 1 diff --git a/store/storetest/session_store.go b/store/storetest/session_store.go index 620931bfc9..3588f16930 100644 --- a/store/storetest/session_store.go +++ b/store/storetest/session_store.go @@ -33,6 +33,7 @@ func TestSessionStore(t *testing.T, ss store.Store) { t.Run("SessionUpdateDeviceId2", func(t *testing.T) { testSessionUpdateDeviceId2(t, ss) }) t.Run("UpdateExpiresAt", func(t *testing.T) { testSessionStoreUpdateExpiresAt(t, ss) }) t.Run("UpdateLastActivityAt", func(t *testing.T) { testSessionStoreUpdateLastActivityAt(t, ss) }) + t.Run("GetLastSessionRowCreateAt", func(t *testing.T) { testSessionStoreGetLastSessionRowCreateAt(t, ss) }) t.Run("SessionCount", func(t *testing.T) { testSessionCount(t, ss) }) t.Run("GetSessionsExpired", func(t *testing.T) { testGetSessionsExpired(t, ss) }) t.Run("UpdateExpiredNotify", func(t *testing.T) { testUpdateExpiredNotify(t, ss) }) @@ -46,6 +47,23 @@ func testSessionStoreSave(t *testing.T, ss store.Store) { require.NoError(t, err) } +func testSessionStoreGetLastSessionRowCreateAt(t *testing.T, ss store.Store) { + s1 := &model.Session{} + s1.UserId = model.NewId() + _, err := ss.Session().Save(s1) + require.NoError(t, err) + + latestSessionUserid := model.NewId() + s2 := &model.Session{} + s2.UserId = latestSessionUserid + latestSession, err := ss.Session().Save(s2) + require.NoError(t, err) + + createAt, err := ss.Session().GetLastSessionRowCreateAt() + require.NoError(t, err) + assert.Equal(t, latestSession.CreateAt, createAt) +} + func testSessionGet(t *testing.T, ss store.Store) { s1 := &model.Session{} s1.UserId = model.NewId() diff --git a/store/timerlayer/timerlayer.go b/store/timerlayer/timerlayer.go index 15bc065dc9..fa37d086fa 100644 --- a/store/timerlayer/timerlayer.go +++ b/store/timerlayer/timerlayer.go @@ -4970,6 +4970,22 @@ func (s *TimerLayerPostStore) GetFlaggedPostsForTeam(userID string, teamID strin return result, err } +func (s *TimerLayerPostStore) GetLastPostRowCreateAt() (int64, error) { + start := timemodule.Now() + + result, err := s.PostStore.GetLastPostRowCreateAt() + + elapsed := float64(timemodule.Since(start)) / float64(timemodule.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("PostStore.GetLastPostRowCreateAt", success, elapsed) + } + return result, err +} + func (s *TimerLayerPostStore) GetMaxPostSize() int { start := timemodule.Now() @@ -6553,6 +6569,22 @@ func (s *TimerLayerSessionStore) Get(ctx context.Context, sessionIDOrToken strin return result, err } +func (s *TimerLayerSessionStore) GetLastSessionRowCreateAt() (int64, error) { + start := timemodule.Now() + + result, err := s.SessionStore.GetLastSessionRowCreateAt() + + elapsed := float64(timemodule.Since(start)) / float64(timemodule.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("SessionStore.GetLastSessionRowCreateAt", success, elapsed) + } + return result, err +} + func (s *TimerLayerSessionStore) GetSessions(userID string) ([]*model.Session, error) { start := timemodule.Now() diff --git a/templates/inactivity_body.html b/templates/inactivity_body.html new file mode 100644 index 0000000000..d8548644a0 --- /dev/null +++ b/templates/inactivity_body.html @@ -0,0 +1,526 @@ +{{define "inactivity_body"}} + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + +
+ +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + +
+
{{.Props.Title}}
+
+
{{.Props.SubTitle}}
+
+
+
    +
  • {{.Props.InfoBullet}}{{.Props.Channels}}
  • +
  • {{.Props.InfoBullet1}}{{.Props.Playbooks}}
  • +
  • {{.Props.InfoBullet2}}{{.Props.Boards}}
  • +
+
+
+
{{.Props.Info}}
+
+ + + + +
+ + {{.Props.Button}} + +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + +
+ +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + + + + +
+
{{.Props.QuestionTitle}}
+
+
{{.Props.QuestionInfo}} + + {{.Props.SupportEmail}} + +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+
{{.Props.Organization}} + {{.Props.FooterV2}} +
+
+
+ +
+
+ +
+
+ +
+ + + + +{{end}} diff --git a/templates/inactivity_body.mjml b/templates/inactivity_body.mjml new file mode 100644 index 0000000000..50dac0afc4 --- /dev/null +++ b/templates/inactivity_body.mjml @@ -0,0 +1,62 @@ + + + + + + + + + + + + {{.Props.Title}} + + + {{.Props.SubTitle}} + + +
    +
  • {{.Props.InfoBullet}}{{.Props.Channels}}
  • +
  • {{.Props.InfoBullet1}}{{.Props.Playbooks}}
  • +
  • {{.Props.InfoBullet2}}{{.Props.Boards}}
  • +
+
+ + {{.Props.Info}} + + {{.Props.Button}} +
+
+ + + + + + + + + + + {{.Props.QuestionTitle}} + + + {{.Props.QuestionInfo}} + + {{.Props.SupportEmail}} + + + + + + + + + {{.Props.Organization}} + {{.Props.FooterV2}} + + + + +
+
+