[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 <mattermod@users.noreply.github.com>
Этот коммит содержится в:
Allan Guwatudde
2022-02-22 20:41:58 +03:00
коммит произвёл GitHub
родитель 1b1ba687bb
Коммит 8d6d1c51c2
17 изменённых файлов: 1038 добавлений и 1 удалений

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

@@ -762,6 +762,39 @@ func (es *Service) SendAtUserLimitWarningEmail(email string, locale string, site
return true, nil 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 { func (es *Service) SendLicenseUpForRenewalEmail(email, name, locale, siteURL, renewalLink string, daysToExpiration int) error {
T := i18n.GetUserTranslations(locale) T := i18n.GetUserTranslations(locale)
subject := T("api.templates.license_up_for_renewal_subject") 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["Button"] = T("api.templates.license_up_for_renewal_renew_now")
data.Props["ButtonURL"] = renewalLink data.Props["ButtonURL"] = renewalLink
data.Props["QuestionTitle"] = T("api.templates.questions_footer.title") data.Props["QuestionTitle"] = T("api.templates.questions_footer.title")
data.Props["SupportEmail"] = "feedback@mattermost.com"
data.Props["QuestionInfo"] = T("api.templates.questions_footer.info") data.Props["QuestionInfo"] = T("api.templates.questions_footer.info")
body, err := es.templatesContainer.RenderToString("license_up_for_renewal", data) body, err := es.templatesContainer.RenderToString("license_up_for_renewal", data)

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

@@ -633,6 +633,7 @@ func NewServer(options ...Option) (*Server, error) {
s.Go(func() { s.Go(func() {
appInstance := New(ServerConnector(s.Channels())) appInstance := New(ServerConnector(s.Channels()))
s.runLicenseExpirationCheckJob() s.runLicenseExpirationCheckJob()
s.runInactivityCheckJob()
runDNDStatusExpireJob(appInstance) runDNDStatusExpireJob(appInstance)
}) })
s.runJobs() s.runJobs()
@@ -1478,6 +1479,12 @@ func runJobsCleanupJob(s *Server) {
}, time.Hour*24) }, time.Hour*24)
} }
func (s *Server) runInactivityCheckJob() {
model.CreateRecurringTask("Server inactivity Check", func() {
s.doInactivityCheck()
}, time.Hour*24)
}
func (s *Server) runLicenseExpirationCheckJob() { func (s *Server) runLicenseExpirationCheckJob() {
s.doLicenseExpirationCheck() s.doLicenseExpirationCheck()
model.CreateRecurringTask("License Expiration Check", func() { model.CreateRecurringTask("License Expiration Check", func() {

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

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

@@ -7,6 +7,14 @@
"id": "August", "id": "August",
"translation": "August" "translation": "August"
}, },
{
"id": "Boards",
"translation": "Boards"
},
{
"id": "Channels",
"translation": "Channels"
},
{ {
"id": "December", "id": "December",
"translation": "December" "translation": "December"
@@ -43,6 +51,10 @@
"id": "October", "id": "October",
"translation": "October" "translation": "October"
}, },
{
"id": "Playbooks",
"translation": "Playbooks"
},
{ {
"id": "September", "id": "September",
"translation": "September" "translation": "September"
@@ -3245,7 +3257,7 @@
}, },
{ {
"id": "api.templates.email_footer_v2", "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", "id": "api.templates.email_info1",
@@ -3563,6 +3575,38 @@
"id": "api.templates.reset_subject", "id": "api.templates.reset_subject",
"translation": "[{{ .SiteName }}] Reset your password" "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 teams productivity!"
},
{
"id": "api.templates.server_inactivity_subtitle",
"translation": "Hey {{.Name}}, weve 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", "id": "api.templates.signin_change_email.body.info",
"translation": "You updated your sign-in method on {{ .SiteName }} to {{.Method}}." "translation": "You updated your sign-in method on {{ .SiteName }} to {{.Method}}."

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

@@ -72,6 +72,8 @@ type FeatureFlags struct {
NormalizeLdapDNs bool NormalizeLdapDNs bool
EnableInactivityCheckJob bool
// Enable special onboarding flow for first admin // Enable special onboarding flow for first admin
UseCaseOnboarding bool UseCaseOnboarding bool
@@ -105,6 +107,7 @@ func (f *FeatureFlags) SetDefaults() {
f.InlinePostEditing = false f.InlinePostEditing = false
f.BoardsDataRetention = false f.BoardsDataRetention = false
f.NormalizeLdapDNs = false f.NormalizeLdapDNs = false
f.EnableInactivityCheckJob = true
f.UseCaseOnboarding = false f.UseCaseOnboarding = false
f.WorkspaceOptimizationDashboard = false f.WorkspaceOptimizationDashboard = false
f.GraphQL = false f.GraphQL = false

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

@@ -5489,6 +5489,24 @@ func (s *OpenTracingLayerPostStore) GetFlaggedPostsForTeam(userID string, teamID
return result, err 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 { func (s *OpenTracingLayerPostStore) GetMaxPostSize() int {
origCtx := s.Root.Store.Context() origCtx := s.Root.Store.Context()
span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "PostStore.GetMaxPostSize") 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 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) { func (s *OpenTracingLayerSessionStore) GetSessions(userID string) ([]*model.Session, error) {
origCtx := s.Root.Store.Context() origCtx := s.Root.Store.Context()
span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "SessionStore.GetSessions") span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "SessionStore.GetSessions")

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

@@ -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 { func (s *RetryLayerPostStore) GetMaxPostSize() int {
return s.PostStore.GetMaxPostSize() 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) { func (s *RetryLayerSessionStore) GetSessions(userID string) ([]*model.Session, error) {
tries := 0 tries := 0

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

@@ -2057,6 +2057,17 @@ func (s *SqlPostStore) AnalyticsPostCount(teamId string, mustHaveFile bool, must
return v, nil 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) { func (s *SqlPostStore) GetPostsCreatedAt(channelId string, time int64) ([]*model.Post, error) {
query := `SELECT * FROM Posts WHERE CreateAt = ? AND ChannelId = ?` query := `SELECT * FROM Posts WHERE CreateAt = ? AND ChannelId = ?`

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

@@ -217,6 +217,17 @@ func (me SqlSessionStore) UpdateExpiresAt(sessionId string, time int64) error {
return nil 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 { func (me SqlSessionStore) UpdateLastActivityAt(sessionId string, time int64) error {
_, err := me.GetMasterX().Exec("UPDATE Sessions SET LastActivityAt = ? WHERE Id = ?", time, sessionId) _, err := me.GetMasterX().Exec("UPDATE Sessions SET LastActivityAt = ? WHERE Id = ?", time, sessionId)
if err != nil { if err != nil {

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

@@ -347,6 +347,7 @@ type PostStore interface {
AnalyticsPostCount(teamID string, mustHaveFile bool, mustHaveHashtag bool) (int64, error) AnalyticsPostCount(teamID string, mustHaveFile bool, mustHaveHashtag bool) (int64, error)
ClearCaches() ClearCaches()
InvalidateLastPostTimeCache(channelID string) InvalidateLastPostTimeCache(channelID string)
GetLastPostRowCreateAt() (int64, error)
GetPostsCreatedAt(channelID string, time int64) ([]*model.Post, error) GetPostsCreatedAt(channelID string, time int64) ([]*model.Post, error)
Overwrite(post *model.Post) (*model.Post, error) Overwrite(post *model.Post) (*model.Post, error)
OverwriteMultiple(posts []*model.Post) ([]*model.Post, int, error) OverwriteMultiple(posts []*model.Post) ([]*model.Post, int, error)
@@ -462,6 +463,7 @@ type SessionStore interface {
Remove(sessionIDOrToken string) error Remove(sessionIDOrToken string) error
RemoveAllSessions() error RemoveAllSessions() error
PermanentDeleteSessionsByUser(teamID string) error PermanentDeleteSessionsByUser(teamID string) error
GetLastSessionRowCreateAt() (int64, error)
UpdateExpiresAt(sessionID string, time int64) error UpdateExpiresAt(sessionID string, time int64) error
UpdateLastActivityAt(sessionID string, time int64) error UpdateLastActivityAt(sessionID string, time int64) error
UpdateRoles(userID string, roles string) (string, error) UpdateRoles(userID string, roles string) (string, error)

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

@@ -252,6 +252,27 @@ func (_m *PostStore) GetFlaggedPostsForTeam(userID string, teamID string, offset
return r0, r1 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: // GetMaxPostSize provides a mock function with given fields:
func (_m *PostStore) GetMaxPostSize() int { func (_m *PostStore) GetMaxPostSize() int {
ret := _m.Called() ret := _m.Called()

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

@@ -74,6 +74,27 @@ func (_m *SessionStore) Get(ctx context.Context, sessionIDOrToken string) (*mode
return r0, r1 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 // GetSessions provides a mock function with given fields: userID
func (_m *SessionStore) GetSessions(userID string) ([]*model.Session, error) { func (_m *SessionStore) GetSessions(userID string) ([]*model.Session, error) {
ret := _m.Called(userID) ret := _m.Called(userID)

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

@@ -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("GetFlaggedPosts", func(t *testing.T) { testPostStoreGetFlaggedPosts(t, ss) })
t.Run("GetFlaggedPostsForChannel", func(t *testing.T) { testPostStoreGetFlaggedPostsForChannel(t, ss) }) t.Run("GetFlaggedPostsForChannel", func(t *testing.T) { testPostStoreGetFlaggedPostsForChannel(t, ss) })
t.Run("GetPostsCreatedAt", func(t *testing.T) { testPostStoreGetPostsCreatedAt(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("Overwrite", func(t *testing.T) { testPostStoreOverwrite(t, ss) })
t.Run("OverwriteMultiple", func(t *testing.T) { testPostStoreOverwriteMultiple(t, ss) }) t.Run("OverwriteMultiple", func(t *testing.T) { testPostStoreOverwriteMultiple(t, ss) })
t.Run("GetPostsByIds", func(t *testing.T) { testPostStoreGetPostsByIds(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") 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) { func testPostStoreGetPostsCreatedAt(t *testing.T, ss store.Store) {
createTime := model.GetMillis() + 1 createTime := model.GetMillis() + 1

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

@@ -33,6 +33,7 @@ func TestSessionStore(t *testing.T, ss store.Store) {
t.Run("SessionUpdateDeviceId2", func(t *testing.T) { testSessionUpdateDeviceId2(t, ss) }) t.Run("SessionUpdateDeviceId2", func(t *testing.T) { testSessionUpdateDeviceId2(t, ss) })
t.Run("UpdateExpiresAt", func(t *testing.T) { testSessionStoreUpdateExpiresAt(t, ss) }) t.Run("UpdateExpiresAt", func(t *testing.T) { testSessionStoreUpdateExpiresAt(t, ss) })
t.Run("UpdateLastActivityAt", func(t *testing.T) { testSessionStoreUpdateLastActivityAt(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("SessionCount", func(t *testing.T) { testSessionCount(t, ss) })
t.Run("GetSessionsExpired", func(t *testing.T) { testGetSessionsExpired(t, ss) }) t.Run("GetSessionsExpired", func(t *testing.T) { testGetSessionsExpired(t, ss) })
t.Run("UpdateExpiredNotify", func(t *testing.T) { testUpdateExpiredNotify(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) 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) { func testSessionGet(t *testing.T, ss store.Store) {
s1 := &model.Session{} s1 := &model.Session{}
s1.UserId = model.NewId() s1.UserId = model.NewId()

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

@@ -4970,6 +4970,22 @@ func (s *TimerLayerPostStore) GetFlaggedPostsForTeam(userID string, teamID strin
return result, err 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 { func (s *TimerLayerPostStore) GetMaxPostSize() int {
start := timemodule.Now() start := timemodule.Now()
@@ -6553,6 +6569,22 @@ func (s *TimerLayerSessionStore) Get(ctx context.Context, sessionIDOrToken strin
return result, err 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) { func (s *TimerLayerSessionStore) GetSessions(userID string) ([]*model.Session, error) {
start := timemodule.Now() start := timemodule.Now()

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

@@ -0,0 +1,526 @@
{{define "inactivity_body"}}
<!-- FILE: inactivity_body.mjml -->
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title>
</title>
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css">
#outlook a {
padding: 0;
}
body {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table,
td {
border-collapse: collapse;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
p {
display: block;
margin: 13px 0;
}
</style>
<!--[if mso]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix { width:100% !important; }
</style>
<![endif]-->
<!--[if !mso]><!-->
<link href="https://fonts.googleapis.com/css?family=Open+Sans:300,400,500,700" rel="stylesheet" type="text/css">
<style type="text/css">
@import url(https://fonts.googleapis.com/css?family=Open+Sans:300,400,500,700);
</style>
<!--<![endif]-->
<style type="text/css">
@media only screen and (min-width:480px) {
.mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
}
</style>
<style media="screen and (min-width:480px)">
.moz-text-html .mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
</style>
<style type="text/css">
@media only screen and (max-width:480px) {
table.mj-full-width-mobile {
width: 100% !important;
}
td.mj-full-width-mobile {
width: auto !important;
}
}
</style>
<style type="text/css">
@import url(https://fonts.googleapis.com/css?family=Open+Sans:300,400,500,600,700);
.emailBody {
background: #F3F3F3 !important;
}
.emailBody a {
text-decoration: none !important;
color: #1C58D9 !important;
}
.title div {
font-weight: 600 !important;
font-size: 28px !important;
line-height: 36px !important;
letter-spacing: -0.01em !important;
color: #3F4350 !important;
}
.subTitle div {
font-size: 16px !important;
line-height: 24px !important;
color: rgba(63, 67, 80, 0.64) !important;
}
.subTitle a {
color: rgb(28, 88, 217) !important;
}
.button a {
background-color: #1C58D9 !important;
font-weight: 600 !important;
font-size: 16px !important;
line-height: 18px !important;
color: #FFFFFF !important;
padding: 15px 24px !important;
}
.messageButton a {
background-color: #FFFFFF !important;
border: 1px solid #FFFFFF !important;
box-sizing: border-box !important;
color: #1C58D9 !important;
padding: 12px 20px !important;
font-weight: 600 !important;
font-size: 14px !important;
line-height: 14px !important;
}
.info div {
font-size: 14px !important;
line-height: 20px !important;
color: #3F4350 !important;
padding: 40px 0px !important;
}
.footerTitle div {
font-weight: 600 !important;
font-size: 16px !important;
line-height: 24px !important;
color: #3F4350 !important;
padding: 0px 0px 4px 0px !important;
}
.footerInfo div {
font-size: 14px !important;
line-height: 20px !important;
color: #3F4350 !important;
padding: 0px 48px 0px 48px !important;
}
.footerInfo a {
color: #1C58D9 !important;
}
.appDownloadButton a {
background-color: #FFFFFF !important;
border: 1px solid #1C58D9 !important;
box-sizing: border-box !important;
color: #1C58D9 !important;
padding: 13px 20px !important;
font-weight: 600 !important;
font-size: 14px !important;
line-height: 14px !important;
}
.emailFooter div {
font-size: 12px !important;
line-height: 16px !important;
color: rgba(63, 67, 80, 0.56) !important;
padding: 8px 24px 8px 24px !important;
}
.postCard {
padding: 0px 24px 40px 24px !important;
}
.messageCard {
background: #FFFFFF !important;
border: 1px solid rgba(61, 60, 64, 0.08) !important;
box-sizing: border-box !important;
box-shadow: 0px 8px 24px rgba(0, 0, 0, 0.12) !important;
border-radius: 4px !important;
padding: 32px !important;
}
.messageAvatar img {
width: 32px !important;
height: 32px !important;
padding: 0px !important;
border-radius: 32px !important;
}
.messageAvatarCol {
width: 32px !important;
}
.postNameAndTime {
padding: 0px 0px 4px 0px !important;
display: flex;
}
.senderName {
font-family: Open Sans, sans-serif;
text-align: left !important;
font-weight: 600 !important;
font-size: 14px !important;
line-height: 20px !important;
color: #3F4350 !important;
}
.time {
font-family: Open Sans, sans-serif;
font-size: 12px;
line-height: 16px;
color: rgba(63, 67, 80, 0.56);
padding: 2px 6px;
align-items: center;
float: left;
}
.channelBg {
background: rgba(63, 67, 80, 0.08);
border-radius: 4px;
display: flex;
padding-left: 4px;
}
.channelLogo {
width: 10px;
height: 10px;
padding: 5px 4px 5px 6px;
float: left;
}
.channelName {
font-family: Open Sans, sans-serif;
font-weight: 600;
font-size: 10px;
line-height: 16px;
letter-spacing: 0.01em;
text-transform: uppercase;
color: rgba(63, 67, 80, 0.64);
padding: 2px 6px 2px 0px;
}
.gmChannelCount {
background-color: rgba(63, 67, 80, 0.2);
padding: 0 5px;
border-radius: 2px;
margin-right: 2px;
}
.senderMessage div {
text-align: left !important;
font-size: 14px !important;
line-height: 20px !important;
color: #3F4350 !important;
padding: 0px !important;
}
.senderInfoCol {
width: 394px !important;
padding: 0px 0px 0px 12px !important;
}
@media all and (min-width: 541px) {
.emailBody {
padding: 32px !important;
}
}
@media all and (max-width: 540px) and (min-width: 401px) {
.emailBody {
padding: 16px !important;
}
.messageCard {
padding: 16px !important;
}
.senderInfoCol {
width: 80% !important;
padding: 0px 0px 0px 12px !important;
}
}
@media all and (max-width: 400px) {
.emailBody {
padding: 0px !important;
}
.footerInfo div {
padding: 0px !important;
}
.messageCard {
padding: 16px !important;
}
.postCard {
padding: 0px 0px 40px 0px !important;
}
.senderInfoCol {
width: 80% !important;
padding: 0px 0px 0px 12px !important;
}
}
</style>
</head>
<body style="word-spacing:normal;">
<div class="emailBody" style="background: #F3F3F3;">
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;border-radius:8px;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;border-radius:8px;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:24px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="600px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:552px;" width="552" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:552px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0px 0px 40px 0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:552px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" style="font-size:0px;padding:0px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:132px;">
<img alt height="21" src="{{.Props.SiteURL}}/static/images/logo_email_dark.png" style="border:0;display:block;outline:none;text-decoration:none;height:21.76px;width:100%;font-size:13px;" width="132">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="600px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:552px;" width="552" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:552px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0px 24px 40px 24px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:504px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" class="title" style="font-size:0px;padding:10px 25px;padding-bottom:16px;word-break:break-word;">
<div style="font-family: Open Sans, sans-serif; text-align: left; font-weight: 600; font-size: 28px; line-height: 36px; letter-spacing: -0.01em; color: #3F4350;">{{.Props.Title}}</div>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;padding-bottom:16px;word-break:break-word;">
<div style="font-family:Open Sans, sans-serif;font-size:16px;font-weight:normal;line-height:24px;text-align:left;color:#3F4350;">{{.Props.SubTitle}}</div>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;padding-bottom:16px;word-break:break-word;">
<div style="font-family:Open Sans, sans-serif;font-size:16px;font-weight:normal;line-height:24px;text-align:left;color:#3F4350;">
<ul>
<li>{{.Props.InfoBullet}}<b>{{.Props.Channels}}</b></li>
<li>{{.Props.InfoBullet1}}<b>{{.Props.Playbooks}}</b></li>
<li>{{.Props.InfoBullet2}}<b>{{.Props.Boards}}</b></li>
</ul>
</div>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;padding-bottom:16px;word-break:break-word;">
<div style="font-family:Open Sans, sans-serif;font-size:16px;font-weight:normal;line-height:24px;text-align:left;color:#3F4350;">{{.Props.Info}}</div>
</td>
</tr>
<tr>
<td align="center" vertical-align="middle" class="button" style="font-size:0px;padding:0px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
<tr>
<td align="center" bgcolor="#FFFFFF" role="presentation" style="border:none;border-radius:4px;cursor:auto;mso-padding-alt:10px 25px;background:#FFFFFF;" valign="middle">
<a href="{{.Props.ButtonURL}}" style="display: inline-block; background: #FFFFFF; font-family: Open Sans, sans-serif; margin: 0; text-transform: none; mso-padding-alt: 0px; border-radius: 4px; text-decoration: none; background-color: #1C58D9; font-weight: 600; font-size: 16px; line-height: 18px; color: #FFFFFF; padding: 15px 24px;" target="_blank">
{{.Props.Button}}
</a>
</td>
</tr>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="600px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:552px;" width="552" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:552px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0px 24px 40px 24px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:504px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" style="font-size:0px;padding:0px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:246px;">
<img alt height="auto" src="{{.Props.SiteURL}}/static/images/invite_illustration.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:13px;" width="246">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="600px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:552px;" width="552" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:552px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0px 24px 40px 40px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:488px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" class="footerTitle" style="font-size:0px;padding:24px 0px 0px 0px;word-break:break-word;">
<div style="font-family: Arial; text-align: left; font-weight: 600; font-size: 16px; line-height: 24px; color: #3F4350; padding: 0px 0px 4px 0px;">{{.Props.QuestionTitle}}</div>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:0px 0px;word-break:break-word;">
<div style="font-family:Arial;font-size:14px;font-weight:normal;line-height:20px;text-align:left;color:#3F4350;">{{.Props.QuestionInfo}}
<a href="mailto:{{.Props.SupportEmail}}" style="text-decoration: none; color: #1C58D9;">
{{.Props.SupportEmail}}
</a>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="600px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:552px;" width="552" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:552px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0px 24px 40px 24px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:504px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-top:1px solid #E5E5E5;vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" class="emailFooter" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family: Arial; text-align: center; font-size: 12px; line-height: 16px; color: rgba(63, 67, 80, 0.56); padding: 8px 24px 8px 24px;">{{.Props.Organization}}
{{.Props.FooterV2}}
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>
{{end}}

62
templates/inactivity_body.mjml Обычный файл
Просмотреть файл

@@ -0,0 +1,62 @@
<mjml>
<mj-head>
<mj-include path="./partials/style.mjml" />
</mj-head>
<mj-body css-class="emailBody">
<mj-wrapper mj-class="email">
<mj-include path="./partials/logo.mjml" />
<mj-section padding="0px 24px 40px 24px">
<mj-column>
<mj-text css-class="title" align="left" color="#3F4350" font-size="16px" font-weight="600" line-height="24px" padding-bottom="16px">
{{.Props.Title}}
</mj-text>
<mj-text align="left" color="#3F4350" font-size="16px" font-weight="normal" line-height="24px" padding-bottom="16px">
{{.Props.SubTitle}}
</mj-text>
<mj-text align="left" color="#3F4350" font-size="16px" font-weight="normal" line-height="24px" padding-bottom="16px">
<ul>
<li>{{.Props.InfoBullet}}<b>{{.Props.Channels}}</b></li>
<li>{{.Props.InfoBullet1}}<b>{{.Props.Playbooks}}</b></li>
<li>{{.Props.InfoBullet2}}<b>{{.Props.Boards}}</b></li>
</ul>
</mj-text>
<mj-text align="left" color="#3F4350" font-size="16px" font-weight="normal" line-height="24px" padding-bottom="16px">
{{.Props.Info}}
</mj-text>
<mj-button href="{{.Props.ButtonURL}}" padding="0px" css-class="button">{{.Props.Button}}</mj-button>
</mj-column>
</mj-section>
<mj-section padding="0px 24px 40px 24px">
<mj-column>
<mj-image src="{{.Props.SiteURL}}/static/images/invite_illustration.png" width="246px" padding="0px" />
</mj-column>
</mj-section>
<mj-section padding="0px 24px 40px 40px">
<mj-column>
<mj-text css-class="footerTitle" color="#3F4350" font-size="16px" font-weight="normal" line-height="24px" padding="24px 0px 0px 0px" align="left" font-family="Arial">
{{.Props.QuestionTitle}}
</mj-text>
<mj-text font-size="14px" line-height="20px" font-weight="normal" color="#3F4350" padding="0px 0px" align="left" font-family="Arial">
{{.Props.QuestionInfo}}
<a href='mailto:{{.Props.SupportEmail}}'>
{{.Props.SupportEmail}}
</a>
</mj-text>
</mj-column>
</mj-section>
<mj-section padding="0px 24px 40px 24px">
<mj-column border-top="1px solid #E5E5E5">
<mj-text css-class="emailFooter" font-family="Arial" font-size="12px" line-height="16px" color="#3F4350">
{{.Props.Organization}}
{{.Props.FooterV2}}
</mj-text>
</mj-column>
</mj-section>
</mj-wrapper>
</mj-body>
</mjml>