summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--api/admin.go7
-rw-r--r--api/api.go2
-rw-r--r--api/email_batching.go252
-rw-r--r--api/email_batching_test.go193
-rw-r--r--api/post.go62
-rw-r--r--api/post_test.go39
-rw-r--r--config/config.json5
-rw-r--r--i18n/en.json98
-rw-r--r--model/config.go37
-rw-r--r--model/preference.go13
-rw-r--r--model/user.go18
-rw-r--r--templates/post_batched_body.html43
-rw-r--r--templates/post_batched_post.html30
-rw-r--r--utils/config.go1
-rw-r--r--utils/html.go4
-rw-r--r--webapp/components/admin_console/configuration_settings.jsx2
-rw-r--r--webapp/components/admin_console/connection_security_dropdown_setting.jsx12
-rw-r--r--webapp/components/admin_console/email_settings.jsx50
-rw-r--r--webapp/components/admin_console/log_settings.jsx18
-rw-r--r--webapp/components/admin_console/webserver_mode_dropdown_setting.jsx12
-rw-r--r--webapp/components/user_settings/email_notification_setting.jsx211
-rw-r--r--webapp/components/user_settings/user_settings_notifications.jsx197
-rw-r--r--webapp/i18n/en.json16
-rw-r--r--webapp/sass/routes/_admin-console.scss1
-rw-r--r--webapp/utils/constants.jsx4
25 files changed, 1119 insertions, 208 deletions
diff --git a/api/admin.go b/api/admin.go
index cab55e7d3..3b324c75f 100644
--- a/api/admin.go
+++ b/api/admin.go
@@ -156,6 +156,10 @@ func reloadConfig(c *Context, w http.ResponseWriter, r *http.Request) {
}
utils.LoadConfig(utils.CfgFileName)
+
+ // start/restart email batching job if necessary
+ InitEmailBatching()
+
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
ReturnStatusOK(w)
}
@@ -204,6 +208,9 @@ func saveConfig(c *Context, w http.ResponseWriter, r *http.Request) {
// }
// }
+ // start/restart email batching job if necessary
+ InitEmailBatching()
+
rdata := map[string]string{}
rdata["status"] = "OK"
w.Write([]byte(model.MapToJson(rdata)))
diff --git a/api/api.go b/api/api.go
index 9e73bd125..5373565de 100644
--- a/api/api.go
+++ b/api/api.go
@@ -100,6 +100,8 @@ func InitApi() {
Srv.Router.Handle("/api/{anything:.*}", http.HandlerFunc(Handle404))
utils.InitHTML()
+
+ InitEmailBatching()
}
func HandleEtag(etag string, w http.ResponseWriter, r *http.Request) bool {
diff --git a/api/email_batching.go b/api/email_batching.go
new file mode 100644
index 000000000..aa2836570
--- /dev/null
+++ b/api/email_batching.go
@@ -0,0 +1,252 @@
+// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+package api
+
+import (
+ "database/sql"
+ "fmt"
+ "html/template"
+ "strconv"
+ "time"
+
+ "github.com/mattermost/platform/model"
+ "github.com/mattermost/platform/utils"
+
+ l4g "github.com/alecthomas/log4go"
+ "github.com/nicksnyder/go-i18n/i18n"
+)
+
+const (
+ EMAIL_BATCHING_TASK_NAME = "Email Batching"
+)
+
+var emailBatchingJob *EmailBatchingJob
+
+func InitEmailBatching() {
+ if *utils.Cfg.EmailSettings.EnableEmailBatching {
+ if emailBatchingJob == nil {
+ emailBatchingJob = MakeEmailBatchingJob(*utils.Cfg.EmailSettings.EmailBatchingBufferSize)
+ }
+
+ // note that we don't support changing EmailBatchingBufferSize without restarting the server
+
+ emailBatchingJob.Start()
+ }
+}
+
+func AddNotificationEmailToBatch(user *model.User, post *model.Post, team *model.Team) *model.AppError {
+ if !*utils.Cfg.EmailSettings.EnableEmailBatching {
+ return model.NewLocAppError("AddNotificationEmailToBatch", "api.email_batching.add_notification_email_to_batch.disabled.app_error", nil, "")
+ }
+
+ if !emailBatchingJob.Add(user, post, team) {
+ l4g.Error(utils.T("api.email_batching.add_notification_email_to_batch.channel_full.app_error"))
+ return model.NewLocAppError("AddNotificationEmailToBatch", "api.email_batching.add_notification_email_to_batch.channel_full.app_error", nil, "")
+ }
+
+ return nil
+}
+
+type batchedNotification struct {
+ userId string
+ post *model.Post
+ teamName string
+}
+
+type EmailBatchingJob struct {
+ newNotifications chan *batchedNotification
+ pendingNotifications map[string][]*batchedNotification
+}
+
+func MakeEmailBatchingJob(bufferSize int) *EmailBatchingJob {
+ return &EmailBatchingJob{
+ newNotifications: make(chan *batchedNotification, bufferSize),
+ pendingNotifications: make(map[string][]*batchedNotification),
+ }
+}
+
+func (job *EmailBatchingJob) Start() {
+ if task := model.GetTaskByName(EMAIL_BATCHING_TASK_NAME); task != nil {
+ task.Cancel()
+ }
+
+ l4g.Debug(utils.T("api.email_batching.start.starting"), *utils.Cfg.EmailSettings.EmailBatchingInterval)
+ model.CreateRecurringTask(EMAIL_BATCHING_TASK_NAME, job.CheckPendingEmails, time.Duration(*utils.Cfg.EmailSettings.EmailBatchingInterval)*time.Second)
+}
+
+func (job *EmailBatchingJob) Add(user *model.User, post *model.Post, team *model.Team) bool {
+ notification := &batchedNotification{
+ userId: user.Id,
+ post: post,
+ teamName: team.Name,
+ }
+
+ select {
+ case job.newNotifications <- notification:
+ return true
+ default:
+ // return false if we couldn't queue the email notification so that we can send an immediate email
+ return false
+ }
+}
+
+func (job *EmailBatchingJob) CheckPendingEmails() {
+ job.handleNewNotifications()
+
+ // it's a bit weird to pass the send email function through here, but it makes it so that we can test
+ // without actually sending emails
+ job.checkPendingNotifications(time.Now(), sendBatchedEmailNotification)
+
+ l4g.Debug(utils.T("api.email_batching.check_pending_emails.finished_running"), len(job.pendingNotifications))
+}
+
+func (job *EmailBatchingJob) handleNewNotifications() {
+ receiving := true
+
+ // read in new notifications to send
+ for receiving {
+ select {
+ case notification := <-job.newNotifications:
+ userId := notification.userId
+
+ if _, ok := job.pendingNotifications[userId]; !ok {
+ job.pendingNotifications[userId] = []*batchedNotification{notification}
+ } else {
+ job.pendingNotifications[userId] = append(job.pendingNotifications[userId], notification)
+ }
+ default:
+ receiving = false
+ }
+ }
+}
+
+func (job *EmailBatchingJob) checkPendingNotifications(now time.Time, handler func(string, []*batchedNotification)) {
+ // look for users who've acted since pending posts were received
+ for userId, notifications := range job.pendingNotifications {
+ schan := Srv.Store.Status().Get(userId)
+ pchan := Srv.Store.Preference().Get(userId, model.PREFERENCE_CATEGORY_NOTIFICATIONS, model.PREFERENCE_NAME_EMAIL_INTERVAL)
+ batchStartTime := notifications[0].post.CreateAt
+
+ // check if the user has been active and would've seen any new posts
+ if result := <-schan; result.Err != nil {
+ l4g.Error(utils.T("api.email_batching.check_pending_emails.status.app_error"), result.Err)
+ delete(job.pendingNotifications, userId)
+ continue
+ } else if status := result.Data.(*model.Status); status.LastActivityAt >= batchStartTime {
+ delete(job.pendingNotifications, userId)
+ continue
+ }
+
+ // get how long we need to wait to send notifications to the user
+ var interval int64
+ if result := <-pchan; result.Err != nil {
+ // default to 30 seconds to match the send "immediate" setting
+ interval, _ = strconv.ParseInt(model.PREFERENCE_DEFAULT_EMAIL_INTERVAL, 10, 64)
+ } else {
+ preference := result.Data.(model.Preference)
+
+ if value, err := strconv.ParseInt(preference.Value, 10, 64); err != nil {
+ interval, _ = strconv.ParseInt(model.PREFERENCE_DEFAULT_EMAIL_INTERVAL, 10, 64)
+ } else {
+ interval = value
+ }
+ }
+
+ // send the email notification if it's been long enough
+ if now.Sub(time.Unix(batchStartTime/1000, 0)) > time.Duration(interval)*time.Second {
+ go handler(userId, notifications)
+ delete(job.pendingNotifications, userId)
+ }
+ }
+}
+
+func sendBatchedEmailNotification(userId string, notifications []*batchedNotification) {
+ uchan := Srv.Store.User().Get(userId)
+ pchan := Srv.Store.Preference().Get(userId, model.PREFERENCE_CATEGORY_DISPLAY_SETTINGS, model.PREFERENCE_NAME_DISPLAY_NAME_FORMAT)
+
+ var user *model.User
+ if result := <-uchan; result.Err != nil {
+ l4g.Warn("api.email_batching.send_batched_email_notification.user.app_error")
+ return
+ } else {
+ user = result.Data.(*model.User)
+ }
+
+ translateFunc := utils.GetUserTranslations(user.Locale)
+
+ var displayNameFormat string
+ if result := <-pchan; result.Err != nil && result.Err.DetailedError != sql.ErrNoRows.Error() {
+ l4g.Warn("api.email_batching.send_batched_email_notification.preferences.app_error")
+ return
+ } else if result.Err != nil {
+ // no display name format saved, so fall back to default
+ displayNameFormat = model.PREFERENCE_DEFAULT_DISPLAY_NAME_FORMAT
+ } else {
+ displayNameFormat = result.Data.(model.Preference).Value
+ }
+
+ var contents string
+ for _, notification := range notifications {
+ template := utils.NewHTMLTemplate("post_batched_post", user.Locale)
+
+ contents += renderBatchedPost(template, notification.post, notification.teamName, displayNameFormat, translateFunc)
+ }
+
+ tm := time.Unix(notifications[0].post.CreateAt/1000, 0)
+
+ subject := translateFunc("api.email_batching.send_batched_email_notification.subject", len(notifications), map[string]interface{}{
+ "SiteName": utils.Cfg.TeamSettings.SiteName,
+ "Year": tm.Year(),
+ "Month": translateFunc(tm.Month().String()),
+ "Day": tm.Day(),
+ })
+
+ body := utils.NewHTMLTemplate("post_batched_body", user.Locale)
+ body.Props["SiteURL"] = *utils.Cfg.ServiceSettings.SiteURL
+ body.Props["Posts"] = template.HTML(contents)
+ body.Props["BodyText"] = translateFunc("api.email_batching.send_batched_email_notification.body_text", len(notifications))
+
+ if err := utils.SendMail(user.Email, subject, body.Render()); err != nil {
+ l4g.Warn(utils.T("api.email_batchings.send_batched_email_notification.send.app_error"), user.Email, err)
+ }
+}
+
+func renderBatchedPost(template *utils.HTMLTemplate, post *model.Post, teamName string, displayNameFormat string, translateFunc i18n.TranslateFunc) string {
+ schan := Srv.Store.User().Get(post.UserId)
+ cchan := Srv.Store.Channel().Get(post.ChannelId)
+
+ template.Props["Button"] = translateFunc("api.email_batching.render_batched_post.go_to_post")
+ template.Props["PostMessage"] = getMessageForNotification(post, translateFunc)
+ template.Props["PostLink"] = *utils.Cfg.ServiceSettings.SiteURL + "/" + teamName + "/pl/" + post.Id
+
+ tm := time.Unix(post.CreateAt/1000, 0)
+ timezone, _ := tm.Zone()
+
+ template.Props["Date"] = translateFunc("api.email_batching.render_batched_post.date", map[string]interface{}{
+ "Year": tm.Year(),
+ "Month": translateFunc(tm.Month().String()),
+ "Day": tm.Day(),
+ "Hour": tm.Hour(),
+ "Minute": fmt.Sprintf("%02d", tm.Minute()),
+ "Timezone": timezone,
+ })
+
+ if result := <-schan; result.Err != nil {
+ l4g.Warn(utils.T("api.email_batching.render_batched_post.sender.app_error"))
+ return ""
+ } else {
+ template.Props["SenderName"] = result.Data.(*model.User).GetDisplayNameForPreference(displayNameFormat)
+ }
+
+ if result := <-cchan; result.Err != nil {
+ l4g.Warn(utils.T("api.email_batching.render_batched_post.channel.app_error"))
+ return ""
+ } else if channel := result.Data.(*model.Channel); channel.Type == model.CHANNEL_DIRECT {
+ template.Props["ChannelName"] = translateFunc("api.email_batching.render_batched_post.direct_message")
+ } else {
+ template.Props["ChannelName"] = channel.DisplayName
+ }
+
+ return template.Render()
+}
diff --git a/api/email_batching_test.go b/api/email_batching_test.go
new file mode 100644
index 000000000..d1619f912
--- /dev/null
+++ b/api/email_batching_test.go
@@ -0,0 +1,193 @@
+// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+package api
+
+import (
+ "testing"
+ "time"
+
+ "github.com/mattermost/platform/model"
+ "github.com/mattermost/platform/store"
+)
+
+func TestHandleNewNotifications(t *testing.T) {
+ Setup()
+
+ id1 := model.NewId()
+ id2 := model.NewId()
+ id3 := model.NewId()
+
+ // test queueing of received posts by user
+ job := MakeEmailBatchingJob(128)
+
+ job.handleNewNotifications()
+
+ if len(job.pendingNotifications) != 0 {
+ t.Fatal("shouldn't have added any pending notifications")
+ }
+
+ job.Add(&model.User{Id: id1}, &model.Post{UserId: id1, Message: "test"}, &model.Team{Name: "team"})
+ if len(job.pendingNotifications) != 0 {
+ t.Fatal("shouldn't have added any pending notifications")
+ }
+
+ job.handleNewNotifications()
+ if len(job.pendingNotifications) != 1 {
+ t.Fatal("should have received posts for 1 user")
+ } else if len(job.pendingNotifications[id1]) != 1 {
+ t.Fatal("should have received 1 post for user")
+ }
+
+ job.Add(&model.User{Id: id1}, &model.Post{UserId: id1, Message: "test"}, &model.Team{Name: "team"})
+ job.handleNewNotifications()
+ if len(job.pendingNotifications) != 1 {
+ t.Fatal("should have received posts for 1 user")
+ } else if len(job.pendingNotifications[id1]) != 2 {
+ t.Fatal("should have received 2 posts for user1", job.pendingNotifications[id1])
+ }
+
+ job.Add(&model.User{Id: id2}, &model.Post{UserId: id1, Message: "test"}, &model.Team{Name: "team"})
+ job.handleNewNotifications()
+ if len(job.pendingNotifications) != 2 {
+ t.Fatal("should have received posts for 2 users")
+ } else if len(job.pendingNotifications[id1]) != 2 {
+ t.Fatal("should have received 2 posts for user1")
+ } else if len(job.pendingNotifications[id2]) != 1 {
+ t.Fatal("should have received 1 post for user2")
+ }
+
+ job.Add(&model.User{Id: id2}, &model.Post{UserId: id2, Message: "test"}, &model.Team{Name: "team"})
+ job.Add(&model.User{Id: id1}, &model.Post{UserId: id3, Message: "test"}, &model.Team{Name: "team"})
+ job.Add(&model.User{Id: id3}, &model.Post{UserId: id3, Message: "test"}, &model.Team{Name: "team"})
+ job.Add(&model.User{Id: id2}, &model.Post{UserId: id2, Message: "test"}, &model.Team{Name: "team"})
+ job.handleNewNotifications()
+ if len(job.pendingNotifications) != 3 {
+ t.Fatal("should have received posts for 3 users")
+ } else if len(job.pendingNotifications[id1]) != 3 {
+ t.Fatal("should have received 3 posts for user1")
+ } else if len(job.pendingNotifications[id2]) != 3 {
+ t.Fatal("should have received 3 posts for user2")
+ } else if len(job.pendingNotifications[id3]) != 1 {
+ t.Fatal("should have received 1 post for user3")
+ }
+
+ // test ordering of received posts
+ job = MakeEmailBatchingJob(128)
+
+ job.Add(&model.User{Id: id1}, &model.Post{UserId: id1, Message: "test1"}, &model.Team{Name: "team"})
+ job.Add(&model.User{Id: id1}, &model.Post{UserId: id1, Message: "test2"}, &model.Team{Name: "team"})
+ job.Add(&model.User{Id: id2}, &model.Post{UserId: id1, Message: "test3"}, &model.Team{Name: "team"})
+ job.Add(&model.User{Id: id1}, &model.Post{UserId: id1, Message: "test4"}, &model.Team{Name: "team"})
+ job.Add(&model.User{Id: id2}, &model.Post{UserId: id1, Message: "test5"}, &model.Team{Name: "team"})
+ job.handleNewNotifications()
+ if job.pendingNotifications[id1][0].post.Message != "test1" ||
+ job.pendingNotifications[id1][1].post.Message != "test2" ||
+ job.pendingNotifications[id1][2].post.Message != "test4" {
+ t.Fatal("incorrect order of received posts for user1")
+ } else if job.pendingNotifications[id2][0].post.Message != "test3" ||
+ job.pendingNotifications[id2][1].post.Message != "test5" {
+ t.Fatal("incorrect order of received posts for user2")
+ }
+}
+
+func TestCheckPendingNotifications(t *testing.T) {
+ Setup()
+
+ id1 := model.NewId()
+
+ job := MakeEmailBatchingJob(128)
+ job.pendingNotifications[id1] = []*batchedNotification{
+ {
+ post: &model.Post{
+ UserId: id1,
+ CreateAt: 10000000,
+ },
+ },
+ }
+
+ store.Must(Srv.Store.Status().SaveOrUpdate(&model.Status{
+ UserId: id1,
+ LastActivityAt: 9999000,
+ }))
+ store.Must(Srv.Store.Preference().Save(&model.Preferences{{
+ UserId: id1,
+ Category: model.PREFERENCE_CATEGORY_NOTIFICATIONS,
+ Name: model.PREFERENCE_NAME_EMAIL_INTERVAL,
+ Value: "60",
+ }}))
+
+ // test that notifications aren't sent before interval
+ job.checkPendingNotifications(time.Unix(10001, 0), func(string, []*batchedNotification) {})
+
+ if job.pendingNotifications[id1] == nil || len(job.pendingNotifications[id1]) != 1 {
+ t.Fatal("should'nt have sent queued post")
+ }
+
+ // test that notifications are cleared if the user has acted
+ store.Must(Srv.Store.Status().SaveOrUpdate(&model.Status{
+ UserId: id1,
+ LastActivityAt: 10001000,
+ }))
+
+ job.checkPendingNotifications(time.Unix(10002, 0), func(string, []*batchedNotification) {})
+
+ if job.pendingNotifications[id1] != nil && len(job.pendingNotifications[id1]) != 0 {
+ t.Fatal("should've remove queued post since user acted")
+ }
+
+ // test that notifications are sent if enough time passes since the first message
+ job.pendingNotifications[id1] = []*batchedNotification{
+ {
+ post: &model.Post{
+ UserId: id1,
+ CreateAt: 10060000,
+ Message: "post1",
+ },
+ },
+ {
+ post: &model.Post{
+ UserId: id1,
+ CreateAt: 10090000,
+ Message: "post2",
+ },
+ },
+ }
+
+ received := make(chan *model.Post, 2)
+ timeout := make(chan bool)
+
+ job.checkPendingNotifications(time.Unix(10130, 0), func(s string, notifications []*batchedNotification) {
+ for _, notification := range notifications {
+ received <- notification.post
+ }
+ })
+
+ go func() {
+ // start a timeout to make sure that we don't get stuck here on a failed test
+ time.Sleep(5 * time.Second)
+ timeout <- true
+ }()
+
+ if job.pendingNotifications[id1] != nil && len(job.pendingNotifications[id1]) != 0 {
+ t.Fatal("should've remove queued posts when sending messages")
+ }
+
+ select {
+ case post := <-received:
+ if post.Message != "post1" {
+ t.Fatal("should've received post1 first")
+ }
+ case _ = <-timeout:
+ t.Fatal("timed out waiting for first post notification")
+ }
+
+ select {
+ case post := <-received:
+ if post.Message != "post2" {
+ t.Fatal("should've received post2 second")
+ }
+ case _ = <-timeout:
+ t.Fatal("timed out waiting for second post notification")
+ }
+}
diff --git a/api/post.go b/api/post.go
index a873e16a8..93b88a432 100644
--- a/api/post.go
+++ b/api/post.go
@@ -23,6 +23,7 @@ import (
"github.com/mattermost/platform/model"
"github.com/mattermost/platform/store"
"github.com/mattermost/platform/utils"
+ "github.com/nicksnyder/go-i18n/i18n"
)
const (
@@ -770,6 +771,14 @@ func sendNotificationEmail(c *Context, post *model.Post, user *model.User, chann
return
}
+ if *utils.Cfg.EmailSettings.EnableEmailBatching {
+ if err := AddNotificationEmailToBatch(user, post, team); err == nil {
+ return
+ }
+
+ // fall back to sending a single email if we can't batch it for some reason
+ }
+
var channelName string
var bodyText string
var subjectText string
@@ -802,7 +811,7 @@ func sendNotificationEmail(c *Context, post *model.Post, user *model.User, chann
bodyPage := utils.NewHTMLTemplate("post_body", user.Locale)
bodyPage.Props["SiteURL"] = c.GetSiteURL()
- bodyPage.Props["PostMessage"] = model.ClearMentionTags(post.Message)
+ bodyPage.Props["PostMessage"] = getMessageForNotification(post, userLocale)
bodyPage.Props["TeamLink"] = teamURL + "/pl/" + post.Id
bodyPage.Props["BodyText"] = bodyText
bodyPage.Props["Button"] = userLocale("api.templates.post_body.button")
@@ -811,39 +820,36 @@ func sendNotificationEmail(c *Context, post *model.Post, user *model.User, chann
"Hour": fmt.Sprintf("%02d", tm.Hour()), "Minute": fmt.Sprintf("%02d", tm.Minute()),
"TimeZone": zone, "Month": month, "Day": day}))
- // attempt to fill in a message body if the post doesn't have any text
- if len(strings.TrimSpace(bodyPage.Props["PostMessage"])) == 0 && len(post.Filenames) > 0 {
- // extract the filenames from their paths and determine what type of files are attached
- filenames := make([]string, len(post.Filenames))
- onlyImages := true
- for i, filename := range post.Filenames {
- var err error
- if filenames[i], err = url.QueryUnescape(filepath.Base(filename)); err != nil {
- // this should never error since filepath was escaped using url.QueryEscape
- filenames[i] = filepath.Base(filename)
- }
+ if err := utils.SendMail(user.Email, subjectPage.Render(), bodyPage.Render()); err != nil {
+ l4g.Error(utils.T("api.post.send_notifications_and_forget.send.error"), user.Email, err)
+ }
+}
- ext := filepath.Ext(filename)
- onlyImages = onlyImages && model.IsFileExtImage(ext)
- }
- filenamesString := strings.Join(filenames, ", ")
+func getMessageForNotification(post *model.Post, translateFunc i18n.TranslateFunc) string {
+ if len(strings.TrimSpace(post.Message)) != 0 || len(post.Filenames) == 0 {
+ return post.Message
+ }
- var attachmentPrefix string
- if onlyImages {
- attachmentPrefix = "Image"
- } else {
- attachmentPrefix = "File"
- }
- if len(post.Filenames) > 1 {
- attachmentPrefix += "s"
+ // extract the filenames from their paths and determine what type of files are attached
+ filenames := make([]string, len(post.Filenames))
+ onlyImages := true
+ for i, filename := range post.Filenames {
+ var err error
+ if filenames[i], err = url.QueryUnescape(filepath.Base(filename)); err != nil {
+ // this should never error since filepath was escaped using url.QueryEscape
+ filenames[i] = filepath.Base(filename)
}
- bodyPage.Props["PostMessage"] = userLocale("api.post.send_notifications_and_forget.sent",
- map[string]interface{}{"Prefix": attachmentPrefix, "Filenames": filenamesString})
+ ext := filepath.Ext(filename)
+ onlyImages = onlyImages && model.IsFileExtImage(ext)
}
- if err := utils.SendMail(user.Email, subjectPage.Render(), bodyPage.Render()); err != nil {
- l4g.Error(utils.T("api.post.send_notifications_and_forget.send.error"), user.Email, err)
+ props := map[string]interface{}{"Filenames": strings.Join(filenames, ", ")}
+
+ if onlyImages {
+ return translateFunc("api.post.get_message_for_notification.images_sent", len(filenames), props)
+ } else {
+ return translateFunc("api.post.get_message_for_notification.files_sent", len(filenames), props)
}
}
diff --git a/api/post_test.go b/api/post_test.go
index f27e843da..2239e92cd 100644
--- a/api/post_test.go
+++ b/api/post_test.go
@@ -1147,3 +1147,42 @@ func TestGetFlaggedPosts(t *testing.T) {
t.Fatal("should not have gotten a flagged post")
}
}
+
+func TestGetMessageForNotification(t *testing.T) {
+ Setup()
+ translateFunc := utils.GetUserTranslations("en")
+
+ post := &model.Post{
+ Message: "test",
+ Filenames: model.StringArray{},
+ }
+
+ if getMessageForNotification(post, translateFunc) != "test" {
+ t.Fatal("should've returned message text")
+ }
+
+ post.Filenames = model.StringArray{"test1.png"}
+ if getMessageForNotification(post, translateFunc) != "test" {
+ t.Fatal("should've returned message text, even with attachments")
+ }
+
+ post.Message = ""
+ if message := getMessageForNotification(post, translateFunc); message != "1 image sent: test1.png" {
+ t.Fatal("should've returned number of images:", message)
+ }
+
+ post.Filenames = model.StringArray{"test1.png", "test2.jpg"}
+ if message := getMessageForNotification(post, translateFunc); message != "2 images sent: test1.png, test2.jpg" {
+ t.Fatal("should've returned number of images:", message)
+ }
+
+ post.Filenames = model.StringArray{"test1.go"}
+ if message := getMessageForNotification(post, translateFunc); message != "1 file sent: test1.go" {
+ t.Fatal("should've returned number of files:", message)
+ }
+
+ post.Filenames = model.StringArray{"test1.go", "test2.jpg"}
+ if message := getMessageForNotification(post, translateFunc); message != "2 files sent: test1.go, test2.jpg" {
+ t.Fatal("should've returned number of mixed files:", message)
+ }
+}
diff --git a/config/config.json b/config/config.json
index 9b9bcb670..8374e695b 100644
--- a/config/config.json
+++ b/config/config.json
@@ -110,7 +110,10 @@
"PasswordResetSalt": "vZ4DcKyVVRlKHHJpexcuXzojkE5PZ5eL",
"SendPushNotifications": false,
"PushNotificationServer": "",
- "PushNotificationContents": "generic"
+ "PushNotificationContents": "generic",
+ "EnableEmailBatching": false,
+ "EmailBatchingBufferSize": 256,
+ "EmailBatchingInterval": 30
},
"RateLimitSettings": {
"EnableRateLimiter": true,
diff --git a/i18n/en.json b/i18n/en.json
index 0010b6059..823a6f259 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -628,6 +628,72 @@
"translation": "An unknown error has occurred. Please contact support."
},
{
+ "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."
+ },
+ {
+ "id": "api.email_batching.add_notification_email_to_batch.disabled.app_error",
+ "translation": "Email batching has been disabled by the system administrator"
+ },
+ {
+ "id": "api.email_batching.check_pending_emails.status.app_error",
+ "translation": "Unable to find status of recipient for batched email notification"
+ },
+ {
+ "id": "api.email_batching.check_pending_emails.finished_running",
+ "translation": "Email batching job ran. %v user(s) still have notifications pending."
+ },
+ {
+ "id": "api.email_batching.render_batched_post.channel.app_error",
+ "translation": "Unable to find channel of post for batched email notification"
+ },
+ {
+ "id": "api.email_batching.render_batched_post.date",
+ "translation": "{{.Hour}}:{{.Minute}} {{.Timezone}}, {{.Month}} {{.Day}}"
+ },
+ {
+ "id": "api.email_batching.render_batched_post.direct_message",
+ "translation": "Direct Message"
+ },
+ {
+ "id": "api.email_batching.render_batched_post.go_to_post",
+ "translation": "Go to Post"
+ },
+ {
+ "id": "api.email_batching.render_batched_post.sender.app_error",
+ "translation": "Unable to find sender of post for batched email notification"
+ },
+ {
+ "id": "api.email_batching.send_batched_email_notification.body_text",
+ "translation": {
+ "one": "You have a new message.",
+ "other": "You have {{.Count}} new messages."
+ }
+ },
+ {
+ "id": "api.email_batching.send_batched_email_notification.preferences.app_error",
+ "translation": "Unable to find display preferences of recipient for batched email notification"
+ },
+ {
+ "id": "api.email_batching.send_batched_email_notification.send.app_error",
+ "translation": "Failed to send batched email notification to %v: %v"
+ },
+ {
+ "id": "api.email_batching.send_batched_email_notification.subject",
+ "translation": {
+ "one": "[{{.SiteName}}] New Notification for {{.Month}} {{.Day}}, {{.Year}}",
+ "other": "[{{.SiteName}}] New Notifications for {{.Month}} {{.Day}}, {{.Year}}"
+ }
+ },
+ {
+ "id": "api.email_batching.send_batched_email_notification.user.app_error",
+ "translation": "Unable to find recipient for batched email notification"
+ },
+ {
+ "id": "api.email_batching.start.starting",
+ "translation": "Email batching job starting. Checking for pending emails every %v seconds."
+ },
+ {
"id": "api.emoji.create.duplicate.app_error",
"translation": "Unable to create emoji. Another emoji with the same name already exists."
},
@@ -1032,6 +1098,20 @@
"translation": "You do not have the appropriate permissions"
},
{
+ "id": "api.post.get_message_for_notification.files_sent",
+ "translation": {
+ "one": "{{.Count}} file sent: {{.Filenames}}",
+ "other": "{{.Count}} files sent: {{.Filenames}}"
+ }
+ },
+ {
+ "id": "api.post.get_message_for_notification.images_sent",
+ "translation": {
+ "one": "{{.Count}} image sent: {{.Filenames}}",
+ "other": "{{.Count}} images sent: {{.Filenames}}"
+ }
+ },
+ {
"id": "api.post.get_out_of_channel_mentions.regex.error",
"translation": "Failed to compile @mention regex user_id=%v, err=%v"
},
@@ -2764,6 +2844,18 @@
"translation": "To must be greater than From"
},
{
+ "id": "model.config.is_valid.cluster_email_batching.app_error",
+ "translation": "Unable to enable email batching (EmailSettings.EnableEmailBatching) when clustering (ClusterSettings.Enable) is enabled"
+ },
+ {
+ "id": "model.config.is_valid.email_batching_buffer_size.app_error",
+ "translation": "Invalid email batching buffer size for email settings. Must be zero or a positive number."
+ },
+ {
+ "id": "model.config.is_valid.email_batching_interval.app_error",
+ "translation": "Invalid email batching interval for email settings. Must be 30 seconds or more."
+ },
+ {
"id": "model.config.is_valid.email_reset_salt.app_error",
"translation": "Invalid password reset salt for email settings. Must be 32 chars or more."
},
@@ -2785,7 +2877,7 @@
},
{
"id": "model.config.is_valid.file_preview_height.app_error",
- "translation": "Invalid preview height for file settings. Must be a zero or positive number."
+ "translation": "Invalid preview height for file settings. Must be zero or a positive number."
},
{
"id": "model.config.is_valid.file_preview_width.app_error",
@@ -2948,6 +3040,10 @@
"translation": "Site URL must be a valid URL and start with http:// or https://"
},
{
+ "id": "model.config.is_valid.site_url_email_batching.app_error",
+ "translation": "Unable to enable email batching (EmailSettings.EnableEmailBatching) when SiteURL (ServiceSettings.SiteURL) isn't set."
+ },
+ {
"id": "model.config.is_valid.sitename_length.app_error",
"translation": "Site name must be less than or equal to {{.MaxLength}} characters."
},
diff --git a/model/config.go b/model/config.go
index f73cb290b..32cb501a0 100644
--- a/model/config.go
+++ b/model/config.go
@@ -47,6 +47,9 @@ const (
RESTRICT_EMOJI_CREATION_ADMIN = "admin"
RESTRICT_EMOJI_CREATION_SYSTEM_ADMIN = "system_admin"
+ EMAIL_BATCHING_BUFFER_SIZE = 256
+ EMAIL_BATCHING_INTERVAL = 30
+
SITENAME_MAX_LENGTH = 30
)
@@ -166,6 +169,9 @@ type EmailSettings struct {
SendPushNotifications *bool
PushNotificationServer *string
PushNotificationContents *string
+ EnableEmailBatching *bool
+ EmailBatchingBufferSize *int
+ EmailBatchingInterval *int
}
type RateLimitSettings struct {
@@ -508,6 +514,21 @@ func (o *Config) SetDefaults() {
*o.EmailSettings.FeedbackOrganization = ""
}
+ if o.EmailSettings.EnableEmailBatching == nil {
+ o.EmailSettings.EnableEmailBatching = new(bool)
+ *o.EmailSettings.EnableEmailBatching = false
+ }
+
+ if o.EmailSettings.EmailBatchingBufferSize == nil {
+ o.EmailSettings.EmailBatchingBufferSize = new(int)
+ *o.EmailSettings.EmailBatchingBufferSize = EMAIL_BATCHING_BUFFER_SIZE
+ }
+
+ if o.EmailSettings.EmailBatchingInterval == nil {
+ o.EmailSettings.EmailBatchingInterval = new(int)
+ *o.EmailSettings.EmailBatchingInterval = EMAIL_BATCHING_INTERVAL
+ }
+
if !IsSafeLink(o.SupportSettings.TermsOfServiceLink) {
o.SupportSettings.TermsOfServiceLink = nil
}
@@ -871,6 +892,14 @@ func (o *Config) IsValid() *AppError {
return NewLocAppError("Config.IsValid", "model.config.is_valid.listen_address.app_error", nil, "")
}
+ if *o.ClusterSettings.Enable && *o.EmailSettings.EnableEmailBatching {
+ return NewLocAppError("Config.IsValid", "model.config.is_valid.cluster_email_batching.app_error", nil, "")
+ }
+
+ if len(*o.ServiceSettings.SiteURL) == 0 && *o.EmailSettings.EnableEmailBatching {
+ return NewLocAppError("Config.IsValid", "model.config.is_valid.site_url_email_batching.app_error", nil, "")
+ }
+
if o.TeamSettings.MaxUsersPerTeam <= 0 {
return NewLocAppError("Config.IsValid", "model.config.is_valid.max_users.app_error", nil, "")
}
@@ -947,6 +976,14 @@ func (o *Config) IsValid() *AppError {
return NewLocAppError("Config.IsValid", "model.config.is_valid.email_reset_salt.app_error", nil, "")
}
+ if *o.EmailSettings.EmailBatchingBufferSize <= 0 {
+ return NewLocAppError("Config.IsValid", "model.config.is_valid.email_batching_buffer_size.app_error", nil, "")
+ }
+
+ if *o.EmailSettings.EmailBatchingInterval < 30 {
+ return NewLocAppError("Config.IsValid", "model.config.is_valid.email_batching_interval.app_error", nil, "")
+ }
+
if o.RateLimitSettings.MemoryStoreSize <= 0 {
return NewLocAppError("Config.IsValid", "model.config.is_valid.rate_mem.app_error", nil, "")
}
diff --git a/model/preference.go b/model/preference.go
index 5787fe6ef..cc35768cb 100644
--- a/model/preference.go
+++ b/model/preference.go
@@ -17,8 +17,13 @@ const (
PREFERENCE_CATEGORY_ADVANCED_SETTINGS = "advanced_settings"
PREFERENCE_CATEGORY_FLAGGED_POST = "flagged_post"
- PREFERENCE_CATEGORY_DISPLAY_SETTINGS = "display_settings"
- PREFERENCE_NAME_COLLAPSE_SETTING = "collapse_previews"
+ PREFERENCE_CATEGORY_DISPLAY_SETTINGS = "display_settings"
+ PREFERENCE_NAME_COLLAPSE_SETTING = "collapse_previews"
+ PREFERENCE_NAME_DISPLAY_NAME_FORMAT = "name_format"
+ PREFERENCE_VALUE_DISPLAY_NAME_NICKNAME = "nickname_full_name"
+ PREFERENCE_VALUE_DISPLAY_NAME_FULL = "full_name"
+ PREFERENCE_VALUE_DISPLAY_NAME_USERNAME = "username"
+ PREFERENCE_DEFAULT_DISPLAY_NAME_FORMAT = PREFERENCE_VALUE_DISPLAY_NAME_USERNAME
PREFERENCE_CATEGORY_THEME = "theme"
// the name for theme props is the team id
@@ -28,6 +33,10 @@ const (
PREFERENCE_CATEGORY_LAST = "last"
PREFERENCE_NAME_LAST_CHANNEL = "channel"
+
+ PREFERENCE_CATEGORY_NOTIFICATIONS = "notifications"
+ PREFERENCE_NAME_EMAIL_INTERVAL = "email_interval"
+ PREFERENCE_DEFAULT_EMAIL_INTERVAL = "30" // default to match the interval of the "immediate" setting (ie 30 seconds)
)
type Preference struct {
diff --git a/model/user.go b/model/user.go
index bad616d73..d8ad54065 100644
--- a/model/user.go
+++ b/model/user.go
@@ -295,6 +295,24 @@ func (u *User) GetDisplayName() string {
}
}
+func (u *User) GetDisplayNameForPreference(nameFormat string) string {
+ displayName := u.Username
+
+ if nameFormat == PREFERENCE_VALUE_DISPLAY_NAME_NICKNAME {
+ if u.Nickname != "" {
+ displayName = u.Nickname
+ } else if fullName := u.GetFullName(); fullName != "" {
+ displayName = fullName
+ }
+ } else if nameFormat == PREFERENCE_VALUE_DISPLAY_NAME_FULL {
+ if fullName := u.GetFullName(); fullName != "" {
+ displayName = fullName
+ }
+ }
+
+ return displayName
+}
+
func IsValidUserRoles(userRoles string) bool {
roles := strings.Split(userRoles, " ")
diff --git a/templates/post_batched_body.html b/templates/post_batched_body.html
new file mode 100644
index 000000000..6efca4516
--- /dev/null
+++ b/templates/post_batched_body.html
@@ -0,0 +1,43 @@
+{{define "post_batched_body"}}
+
+<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%" style="margin-top: 20px; line-height: 1.7; color: #555;">
+ <tr>
+ <td>
+ <table align="center" border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 660px; font-family: Helvetica, Arial, sans-serif; font-size: 14px; background: #FFF;">
+ <tr>
+ <td style="border: 1px solid #ddd;">
+ <table align="center" border="0" cellpadding="0" cellspacing="0" width="100%" style="border-collapse: collapse;">
+ <tr>
+ <td style="padding: 20px 20px 10px; text-align:left;">
+ <img src="{{.Props.SiteURL}}/static/images/logo-email.png" width="130px" style="opacity: 0.5" alt="">
+ </td>
+ </tr>
+ <tr>
+ <td>
+ <table border="0" cellpadding="0" cellspacing="0" style="padding: 20px 50px 0; text-align: center; width: 100%;">
+ <tr>
+ <td style="border-bottom: 1px solid #ddd; margin: 10px 0 20px;">
+ <p style="font-weight: normal; text-align: left;">
+ {{.Props.BodyText}}
+ </p>
+ {{.Props.Posts}}
+ </td>
+ </tr>
+ <tr>
+ {{template "email_info" . }}
+ </tr>
+ </table>
+ </td>
+ </tr>
+ <tr>
+ {{template "email_footer" . }}
+ </tr>
+ </table>
+ </td>
+ </tr>
+ </table>
+ </td>
+ </tr>
+</table>
+
+{{end}}
diff --git a/templates/post_batched_post.html b/templates/post_batched_post.html
new file mode 100644
index 000000000..76b35b0cd
--- /dev/null
+++ b/templates/post_batched_post.html
@@ -0,0 +1,30 @@
+{{define "post_batched_post"}}
+
+<table style="border-top: 1px solid #ddd; padding: 20px 0; width: 100%">
+ <tr>
+ <td style="text-align: left">
+ <span style="font-size: 16px; font-weight: bold; color: #555;" >
+ {{.Props.ChannelName}}
+ </span>
+ <br/>
+ <span style="font-weight: bold">
+ @{{.Props.SenderName}}
+ </span>
+ <span style="color: #AAA; font-size: 12px; margin-left: 2px;">
+ {{.Props.Date}}
+ </span>
+ </td>
+ <td valign="top" style="width: 120px; padding-top: 3px;">
+ <a href="{{.Props.PostLink}}" style="background: #2389D7; display: inline-block; border-radius: 2px; color: #fff; padding: 4px 0; width: 120px; text-decoration: none;">
+ {{.Props.Button}}
+ </a>
+ </td>
+ </tr>
+ <tr>
+ <td colspan=2>
+ <pre style="text-align:left; font-family: 'Lato', sans-serif; margin: 0px; white-space: pre-wrap; white-space: -moz-pre-wrap; white-space: -pre-wrap; white-space: -o-pre-wrap; word-wrap: break-word; line-height: 20px;">{{.Props.PostMessage}}</pre>
+ </td>
+ </tr>
+</table>
+
+{{end}}
diff --git a/utils/config.go b/utils/config.go
index 06d0dc440..c97d465f1 100644
--- a/utils/config.go
+++ b/utils/config.go
@@ -238,6 +238,7 @@ func getClientConfig(c *model.Config) map[string]string {
props["EnableSignInWithEmail"] = strconv.FormatBool(*c.EmailSettings.EnableSignInWithEmail)
props["EnableSignInWithUsername"] = strconv.FormatBool(*c.EmailSettings.EnableSignInWithUsername)
props["RequireEmailVerification"] = strconv.FormatBool(c.EmailSettings.RequireEmailVerification)
+ props["EnableEmailBatching"] = strconv.FormatBool(*c.EmailSettings.EnableEmailBatching)
props["EnableSignUpWithGitLab"] = strconv.FormatBool(c.GitLabSettings.Enable)
diff --git a/utils/html.go b/utils/html.go
index 1df336c52..05f36eb2c 100644
--- a/utils/html.go
+++ b/utils/html.go
@@ -18,7 +18,7 @@ var htmlTemplates *template.Template
type HTMLTemplate struct {
TemplateName string
- Props map[string]string
+ Props map[string]interface{}
Html map[string]template.HTML
Locale string
}
@@ -71,7 +71,7 @@ func InitHTMLWithDir(dir string) {
func NewHTMLTemplate(templateName string, locale string) *HTMLTemplate {
return &HTMLTemplate{
TemplateName: templateName,
- Props: make(map[string]string),
+ Props: make(map[string]interface{}),
Html: make(map[string]template.HTML),
Locale: locale,
}
diff --git a/webapp/components/admin_console/configuration_settings.jsx b/webapp/components/admin_console/configuration_settings.jsx
index 6a07e31cd..1207f1f79 100644
--- a/webapp/components/admin_console/configuration_settings.jsx
+++ b/webapp/components/admin_console/configuration_settings.jsx
@@ -69,7 +69,7 @@ export default class ConfigurationSettings extends AdminSettings {
helpText={
<FormattedMessage
id='admin.service.siteURLDescription'
- defaultMessage='The URL, including port number and protocol, from which users will access Mattermost. Leave blank to automatically configure based on incoming traffic.'
+ defaultMessage='The URL, including post number and protocol, that users will use to access Mattermost. Leave blank to automatically configure based on incoming traffic.'
/>
}
value={this.state.siteURL}
diff --git a/webapp/components/admin_console/connection_security_dropdown_setting.jsx b/webapp/components/admin_console/connection_security_dropdown_setting.jsx
index b3e9ac31c..09768049e 100644
--- a/webapp/components/admin_console/connection_security_dropdown_setting.jsx
+++ b/webapp/components/admin_console/connection_security_dropdown_setting.jsx
@@ -14,13 +14,13 @@ const CONNECTION_SECURITY_HELP_TEXT = (
>
<tbody>
<tr>
- <td className='help-text'>
+ <td>
<FormattedMessage
id='admin.connectionSecurityNone'
defaultMessage='None'
/>
</td>
- <td className='help-text'>
+ <td>
<FormattedMessage
id='admin.connectionSecurityNoneDescription'
defaultMessage='Mattermost will connect over an unsecure connection.'
@@ -28,13 +28,13 @@ const CONNECTION_SECURITY_HELP_TEXT = (
</td>
</tr>
<tr>
- <td className='help-text'>
+ <td>
<FormattedMessage
id='admin.connectionSecurityTls'
defaultMessage='TLS'
/>
</td>
- <td className='help-text'>
+ <td>
<FormattedMessage
id='admin.connectionSecurityTlsDescription'
defaultMessage='Encrypts the communication between Mattermost and your server.'
@@ -42,13 +42,13 @@ const CONNECTION_SECURITY_HELP_TEXT = (
</td>
</tr>
<tr>
- <td className='help-text'>
+ <td>
<FormattedMessage
id='admin.connectionSecurityStart'
defaultMessage='STARTTLS'
/>
</td>
- <td className='help-text'>
+ <td>
<FormattedMessage
id='admin.connectionSecurityStartDescription'
defaultMessage='Takes an existing insecure connection and attempts to upgrade it to a secure connection using TLS.'
diff --git a/webapp/components/admin_console/email_settings.jsx b/webapp/components/admin_console/email_settings.jsx
index 01f38dc21..b05a3f905 100644
--- a/webapp/components/admin_console/email_settings.jsx
+++ b/webapp/components/admin_console/email_settings.jsx
@@ -32,6 +32,7 @@ export default class EmailSettings extends AdminSettings {
config.EmailSettings.SMTPServer = this.state.smtpServer;
config.EmailSettings.SMTPPort = this.state.smtpPort;
config.EmailSettings.ConnectionSecurity = this.state.connectionSecurity;
+ config.EmailSettings.EnableEmailBatching = this.state.enableEmailBatching;
config.ServiceSettings.EnableSecurityFixAlert = this.state.enableSecurityFixAlert;
return config;
@@ -48,6 +49,7 @@ export default class EmailSettings extends AdminSettings {
smtpServer: config.EmailSettings.SMTPServer,
smtpPort: config.EmailSettings.SMTPPort,
connectionSecurity: config.EmailSettings.ConnectionSecurity,
+ enableEmailBatching: config.EmailSettings.EnableEmailBatching,
enableSecurityFixAlert: config.ServiceSettings.EnableSecurityFixAlert
};
}
@@ -64,6 +66,34 @@ export default class EmailSettings extends AdminSettings {
}
renderSettings() {
+ let enableEmailBatchingDisabledText = null;
+
+ if (this.props.config.ClusterSettings.Enable) {
+ enableEmailBatchingDisabledText = (
+ <span
+ key='admin.email.enableEmailBatching.clusterEnabled'
+ className='help-text'
+ >
+ <FormattedHTMLMessage
+ id='admin.email.enableEmailBatching.clusterEnabled'
+ defaultMessage='Email batching cannot be enabled unless the SiteURL is configured in <b>Configuration > SiteURL</b>.'
+ />
+ </span>
+ );
+ } else if (!this.props.config.ServiceSettings.SiteURL) {
+ enableEmailBatchingDisabledText = (
+ <span
+ key='admin.email.enableEmailBatching.siteURL'
+ className='help-text'
+ >
+ <FormattedHTMLMessage
+ id='admin.email.enableEmailBatching.siteURL'
+ defaultMessage='Email batching cannot be enabled unless the SiteURL is configured in <b>Configuration > SiteURL</b>.'
+ />
+ </span>
+ );
+ }
+
return (
<SettingsGroup>
<BooleanSetting
@@ -83,6 +113,26 @@ export default class EmailSettings extends AdminSettings {
value={this.state.sendEmailNotifications}
onChange={this.handleChange}
/>
+ <BooleanSetting
+ id='enableEmailBatching'
+ label={
+ <FormattedMessage
+ id='admin.email.enableEmailBatchingTitle'
+ defaultMessage='Enable Email Batching: '
+ />
+ }
+ helpText={[
+ <FormattedHTMLMessage
+ key='admin.email.enableEmailBatchingDesc'
+ id='admin.email.enableEmailBatchingDesc'
+ defaultMessage='When true, users can have email notifications for multiple direct messages and mentions combined into a single email, configurable in <b>Account Settings > Notifications</b>.'
+ />,
+ enableEmailBatchingDisabledText
+ ]}
+ value={this.state.enableEmailBatching && !this.props.config.ClusterSettings.Enable && this.props.config.ServiceSettings.SiteURL}
+ onChange={this.handleChange}
+ disabled={!this.state.sendEmailNotifications || this.props.config.ClusterSettings.Enable || !this.props.config.ServiceSettings.SiteURL}
+ />
<TextSetting
id='feedbackName'
label={
diff --git a/webapp/components/admin_console/log_settings.jsx b/webapp/components/admin_console/log_settings.jsx
index efd1bf342..31abca316 100644
--- a/webapp/components/admin_console/log_settings.jsx
+++ b/webapp/components/admin_console/log_settings.jsx
@@ -204,7 +204,8 @@ export default class LogSettings extends AdminSettings {
>
<tbody>
<tr>
- <td className='help-text'>{'%T'}</td><td className='help-text'>
+ <td>{'%T'}</td>
+ <td>
<FormattedMessage
id='admin.log.formatTime'
defaultMessage='Time (15:04:05 MST)'
@@ -212,7 +213,8 @@ export default class LogSettings extends AdminSettings {
</td>
</tr>
<tr>
- <td className='help-text'>{'%D'}</td><td className='help-text'>
+ <td>{'%D'}</td>
+ <td>
<FormattedMessage
id='admin.log.formatDateLong'
defaultMessage='Date (2006/01/02)'
@@ -220,7 +222,8 @@ export default class LogSettings extends AdminSettings {
</td>
</tr>
<tr>
- <td className='help-text'>{'%d'}</td><td className='help-text'>
+ <td>{'%d'}</td>
+ <td>
<FormattedMessage
id='admin.log.formatDateShort'
defaultMessage='Date (01/02/06)'
@@ -228,7 +231,8 @@ export default class LogSettings extends AdminSettings {
</td>
</tr>
<tr>
- <td className='help-text'>{'%L'}</td><td className='help-text'>
+ <td>{'%L'}</td>
+ <td>
<FormattedMessage
id='admin.log.formatLevel'
defaultMessage='Level (DEBG, INFO, EROR)'
@@ -236,7 +240,8 @@ export default class LogSettings extends AdminSettings {
</td>
</tr>
<tr>
- <td className='help-text'>{'%S'}</td><td className='help-text'>
+ <td>{'%S'}</td>
+ <td>
<FormattedMessage
id='admin.log.formatSource'
defaultMessage='Source'
@@ -244,7 +249,8 @@ export default class LogSettings extends AdminSettings {
</td>
</tr>
<tr>
- <td className='help-text'>{'%M'}</td><td className='help-text'>
+ <td>{'%M'}</td>
+ <td>
<FormattedMessage
id='admin.log.formatMessage'
defaultMessage='Message'
diff --git a/webapp/components/admin_console/webserver_mode_dropdown_setting.jsx b/webapp/components/admin_console/webserver_mode_dropdown_setting.jsx
index 9581816f1..1616ced23 100644
--- a/webapp/components/admin_console/webserver_mode_dropdown_setting.jsx
+++ b/webapp/components/admin_console/webserver_mode_dropdown_setting.jsx
@@ -15,13 +15,13 @@ const WEBSERVER_MODE_HELP_TEXT = (
>
<tbody>
<tr>
- <td className='help-text'>
+ <td>
<FormattedMessage
id='admin.webserverModeGzip'
defaultMessage='gzip'
/>
</td>
- <td className='help-text'>
+ <td>
<FormattedMessage
id='admin.webserverModeGzipDescription'
defaultMessage='The Mattermost server will serve static files compressed with gzip.'
@@ -29,13 +29,13 @@ const WEBSERVER_MODE_HELP_TEXT = (
</td>
</tr>
<tr>
- <td className='help-text'>
+ <td>
<FormattedMessage
id='admin.webserverModeUncompressed'
defaultMessage='Uncompressed'
/>
</td>
- <td className='help-text'>
+ <td>
<FormattedMessage
id='admin.webserverModeUncompressedDescription'
defaultMessage='The Mattermost server will serve static files uncompressed.'
@@ -43,13 +43,13 @@ const WEBSERVER_MODE_HELP_TEXT = (
</td>
</tr>
<tr>
- <td className='help-text'>
+ <td>
<FormattedMessage
id='admin.webserverModeDisabled'
defaultMessage='Disabled'
/>
</td>
- <td className='help-text'>
+ <td>
<FormattedMessage
id='admin.webserverModeDisabledDescription'
defaultMessage='The Mattermost server will not serve static files.'
diff --git a/webapp/components/user_settings/email_notification_setting.jsx b/webapp/components/user_settings/email_notification_setting.jsx
new file mode 100644
index 000000000..df5f80c64
--- /dev/null
+++ b/webapp/components/user_settings/email_notification_setting.jsx
@@ -0,0 +1,211 @@
+// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import React from 'react';
+
+import {savePreference} from 'utils/async_client.jsx';
+import PreferenceStore from 'stores/preference_store.jsx';
+import {localizeMessage} from 'utils/utils.jsx';
+
+import {FormattedMessage} from 'react-intl';
+import SettingItemMin from 'components/setting_item_min.jsx';
+import SettingItemMax from 'components/setting_item_max.jsx';
+
+import {Preferences} from 'utils/constants.jsx';
+
+const INTERVAL_IMMEDIATE = 30; // "immediate" is a 30 second interval
+const INTERVAL_FIFTEEN_MINUTES = 15 * 60;
+const INTERVAL_HOUR = 60 * 60;
+
+export default class EmailNotificationSetting extends React.Component {
+ static propTypes = {
+ activeSection: React.PropTypes.string.isRequired,
+ updateSection: React.PropTypes.func.isRequired,
+ enableEmail: React.PropTypes.bool.isRequired,
+ onChange: React.PropTypes.func.isRequired,
+ onSubmit: React.PropTypes.func.isRequired,
+ serverError: React.PropTypes.string.isRequired
+ };
+
+ constructor(props) {
+ super(props);
+
+ this.submit = this.submit.bind(this);
+
+ this.expand = this.expand.bind(this);
+ this.collapse = this.collapse.bind(this);
+
+ this.state = {
+ emailInterval: PreferenceStore.getInt(Preferences.CATEGORY_NOTIFICATIONS, Preferences.EMAIL_INTERVAL, INTERVAL_IMMEDIATE)
+ };
+ }
+
+ handleChange(enableEmail, emailInterval) {
+ this.props.onChange(enableEmail);
+ this.setState({emailInterval});
+ }
+
+ submit() {
+ // until the rest of the notification settings are moved to preferences, we have to do this separately
+ savePreference(Preferences.CATEGORY_NOTIFICATIONS, Preferences.EMAIL_INTERVAL, this.state.emailInterval.toString());
+
+ this.props.onSubmit();
+ }
+
+ expand() {
+ this.props.updateSection('email');
+ }
+
+ collapse() {
+ this.props.updateSection('');
+ }
+
+ render() {
+ if (this.props.activeSection !== 'email') {
+ let description;
+
+ if (this.props.enableEmail === 'true') {
+ switch (this.state.emailInterval) {
+ case INTERVAL_IMMEDIATE:
+ description = (
+ <FormattedMessage
+ id='user.settings.notifications.email.immediately'
+ defaultMessage='Immediately'
+ />
+ );
+ break;
+ case INTERVAL_HOUR:
+ description = (
+ <FormattedMessage
+ id='user.settings.notifications.email.everyHour'
+ defaultMessage='Every hour'
+ />
+ );
+ break;
+ default:
+ description = (
+ <FormattedMessage
+ id='user.settings.notifications.email.everyXMinutes'
+ defaultMessage='Every {count, plural, one {minute} other {{count, number} minutes}}'
+ values={{count: this.state.emailInterval / 60}}
+ />
+ );
+ }
+ } else {
+ description = (
+ <FormattedMessage
+ id='user.settings.notifications.email.never'
+ defaultMessage='Never'
+ />
+ );
+ }
+
+ return (
+ <SettingItemMin
+ title={localizeMessage('user.settings.notifications.emailNotifications', 'Send Email notifications')}
+ describe={description}
+ updateSection={this.expand}
+ />
+ );
+ }
+
+ let batchingOptions = null;
+ let batchingInfo = null;
+ if (window.mm_config.EnableEmailBatching === 'true') {
+ batchingOptions = (
+ <div>
+ <div className='radio'>
+ <label>
+ <input
+ type='radio'
+ name='emailNotifications'
+ checked={this.props.enableEmail === 'true' && this.state.emailInterval === INTERVAL_FIFTEEN_MINUTES}
+ onChange={this.handleChange.bind(this, 'true', INTERVAL_FIFTEEN_MINUTES)}
+ />
+ <FormattedMessage
+ id='user.settings.notifications.email.everyXMinutes'
+ defaultMessage='Every {count} minutes'
+ values={{count: INTERVAL_FIFTEEN_MINUTES / 60}}
+ />
+ </label>
+ </div>
+ <div className='radio'>
+ <label>
+ <input
+ type='radio'
+ name='emailNotifications'
+ checked={this.props.enableEmail === 'true' && this.state.emailInterval === INTERVAL_HOUR}
+ onChange={this.handleChange.bind(this, 'true', INTERVAL_HOUR)}
+ />
+ <FormattedMessage
+ id='user.settings.notifications.everyHour'
+ defaultMessage='Every hour'
+ />
+ </label>
+ </div>
+ </div>
+ );
+
+ batchingInfo = (
+ <FormattedMessage
+ id='user.settings.notifications.emailBatchingInfo'
+ defaultMessage='Notifications are combined into a single email and sent at the maximum frequency selected here.'
+ />
+ );
+ }
+
+ return (
+ <SettingItemMax
+ title={localizeMessage('user.settings.notifications.emailNotifications', 'Send email notifications')}
+ inputs={[
+ <div key='userNotificationEmailOptions'>
+ <div className='radio'>
+ <label>
+ <input
+ type='radio'
+ name='emailNotifications'
+ checked={this.props.enableEmail === 'true' && this.state.emailInterval === INTERVAL_IMMEDIATE}
+ onChange={this.handleChange.bind(this, 'true', INTERVAL_IMMEDIATE)}
+ />
+ <FormattedMessage
+ id='user.settings.notifications.email.immediately'
+ defaultMessage='Immediately'
+ />
+ </label>
+ </div>
+ {batchingOptions}
+ <div className='radio'>
+ <label>
+ <input
+ type='radio'
+ name='emailNotifications'
+ checked={this.props.enableEmail === 'false'}
+ onChange={this.handleChange.bind(this, 'false', INTERVAL_IMMEDIATE)}
+ />
+ <FormattedMessage
+ id='user.settings.notifications.never'
+ defaultMessage='Never'
+ />
+ </label>
+ </div>
+ <br/>
+ <div>
+ <FormattedMessage
+ id='user.settings.notifications.emailInfo'
+ defaultMessage='Email notifications that are sent for mentions and direct messages when you are offline or away from {siteName} for more than 5 minutes.'
+ values={{
+ siteName: global.window.mm_config.SiteName
+ }}
+ />
+ {' '}
+ {batchingInfo}
+ </div>
+ </div>
+ ]}
+ submit={this.submit}
+ server_error={this.props.serverError}
+ updateSection={this.collapse}
+ />
+ );
+ }
+} \ No newline at end of file
diff --git a/webapp/components/user_settings/user_settings_notifications.jsx b/webapp/components/user_settings/user_settings_notifications.jsx
index e0e3bf979..17abfa555 100644
--- a/webapp/components/user_settings/user_settings_notifications.jsx
+++ b/webapp/components/user_settings/user_settings_notifications.jsx
@@ -2,8 +2,8 @@
// See License.txt for license information.
import $ from 'jquery';
-import SettingItemMin from '../setting_item_min.jsx';
-import SettingItemMax from '../setting_item_max.jsx';
+import SettingItemMin from 'components/setting_item_min.jsx';
+import SettingItemMax from 'components/setting_item_max.jsx';
import UserStore from 'stores/user_store.jsx';
@@ -12,7 +12,8 @@ import * as AsyncClient from 'utils/async_client.jsx';
import * as UserAgent from 'utils/user_agent.jsx';
import * as Utils from 'utils/utils.jsx';
-import {intlShape, injectIntl, defineMessages, FormattedMessage} from 'react-intl';
+import EmailNotificationSetting from './email_notification_setting.jsx';
+import {FormattedMessage} from 'react-intl';
function getNotificationsStateFromStores() {
var user = UserStore.getCurrentUser();
@@ -30,9 +31,9 @@ function getNotificationsStateFromStores() {
if (user.notify_props && user.notify_props.comments) {
comments = user.notify_props.comments;
}
- var email = 'true';
+ var enableEmail = 'true';
if (user.notify_props && user.notify_props.email) {
- email = user.notify_props.email;
+ enableEmail = user.notify_props.email;
}
var push = 'mention';
if (user.notify_props && user.notify_props.push) {
@@ -78,7 +79,7 @@ function getNotificationsStateFromStores() {
return {
notifyLevel: desktop,
notifyPushLevel: push,
- enableEmail: email,
+ enableEmail,
soundNeeded,
enableSound: sound,
usernameKey,
@@ -91,36 +92,9 @@ function getNotificationsStateFromStores() {
};
}
-const holders = defineMessages({
- desktop: {
- id: 'user.settings.notifications.desktop',
- defaultMessage: 'Send desktop notifications'
- },
- desktopSounds: {
- id: 'user.settings.notifications.desktopSounds',
- defaultMessage: 'Desktop notification sounds'
- },
- emailNotifications: {
- id: 'user.settings.notifications.emailNotifications',
- defaultMessage: 'Email notifications'
- },
- wordsTrigger: {
- id: 'user.settings.notifications.wordsTrigger',
- defaultMessage: 'Words that trigger mentions'
- },
- comments: {
- id: 'user.settings.notifications.comments',
- defaultMessage: 'Comment threads notifications'
- },
- close: {
- id: 'user.settings.notifications.close',
- defaultMessage: 'Close'
- }
-});
-
import React from 'react';
-class NotificationsTab extends React.Component {
+export default class NotificationsTab extends React.Component {
constructor(props) {
super(props);
@@ -142,6 +116,7 @@ class NotificationsTab extends React.Component {
this.state = getNotificationsStateFromStores();
}
+
handleSubmit() {
var data = {};
data.user_id = this.props.user.id;
@@ -178,31 +153,38 @@ class NotificationsTab extends React.Component {
}
);
}
+
handleCancel(e) {
this.updateState();
this.props.updateSection('');
e.preventDefault();
$('.settings-modal .modal-body').scrollTop(0).perfectScrollbar('update');
}
+
updateSection(section) {
this.updateState();
this.props.updateSection(section);
}
+
updateState() {
const newState = getNotificationsStateFromStores();
if (!Utils.areObjectsEqual(newState, this.state)) {
this.setState(newState);
}
}
+
componentDidMount() {
UserStore.addChangeListener(this.onListenerChange);
}
+
componentWillUnmount() {
UserStore.removeChangeListener(this.onListenerChange);
}
+
onListenerChange() {
this.updateState();
}
+
handleNotifyRadio(notifyLevel) {
this.setState({notifyLevel});
this.refs.wrapper.focus();
@@ -222,22 +204,28 @@ class NotificationsTab extends React.Component {
this.setState({enableEmail});
this.refs.wrapper.focus();
}
+
handleSoundRadio(enableSound) {
this.setState({enableSound});
this.refs.wrapper.focus();
}
+
updateUsernameKey(val) {
this.setState({usernameKey: val});
}
+
updateMentionKey(val) {
this.setState({mentionKey: val});
}
+
updateFirstNameKey(val) {
this.setState({firstNameKey: val});
}
+
updateChannelKey(val) {
this.setState({channelKey: val});
}
+
updateCustomMentionKeys() {
var checked = this.refs.customcheck.checked;
@@ -250,10 +238,12 @@ class NotificationsTab extends React.Component {
this.setState({customKeys: '', customKeysChecked: false});
}
}
+
onCustomChange() {
this.refs.customcheck.checked = true;
this.updateCustomMentionKeys();
}
+
createPushNotificationSection() {
var handleUpdateDesktopSection;
if (this.props.activeSection === 'push') {
@@ -312,8 +302,8 @@ class NotificationsTab extends React.Component {
onChange={this.handlePushRadio.bind(this, 'none')}
/>
<FormattedMessage
- id='user.settings.push_notification.off'
- defaultMessage='Off'
+ id='user.settings.notifications.never'
+ defaultMessage='Never'
/>
</label>
</div>
@@ -346,7 +336,7 @@ class NotificationsTab extends React.Component {
return (
<SettingItemMax
- title={Utils.localizeMessage('user.settings.notifications.push', 'Mobile push notifications')}
+ title={Utils.localizeMessage('user.settings.notifications.push', 'Send mobile push notifications')}
extraInfo={extraInfo}
inputs={inputs}
submit={submit}
@@ -367,8 +357,8 @@ class NotificationsTab extends React.Component {
} else if (this.state.notifyPushLevel === 'none') {
describe = (
<FormattedMessage
- id='user.settings.push_notification.off'
- defaultMessage='Off'
+ id='user.settings.notifications.never'
+ defaultMessage='Never'
/>
);
} else if (global.window.mm_config.SendPushNotifications === 'false') {
@@ -393,14 +383,14 @@ class NotificationsTab extends React.Component {
return (
<SettingItemMin
- title={Utils.localizeMessage('user.settings.notifications.push', 'Mobile push notifications')}
+ title={Utils.localizeMessage('user.settings.notifications.push', 'Send mobile push notifications')}
describe={describe}
updateSection={handleUpdateDesktopSection}
/>
);
}
+
render() {
- const {formatMessage} = this.props.intl;
const serverError = this.state.serverError;
var user = this.props.user;
@@ -479,7 +469,7 @@ class NotificationsTab extends React.Component {
desktopSection = (
<SettingItemMax
- title={formatMessage(holders.desktop)}
+ title={Utils.localizeMessage('user.settings.notifications.desktop', 'Send desktop notifications')}
extraInfo={extraInfo}
inputs={inputs}
submit={this.handleSubmit}
@@ -518,7 +508,7 @@ class NotificationsTab extends React.Component {
desktopSection = (
<SettingItemMin
- title={formatMessage(holders.desktop)}
+ title={Utils.localizeMessage('user.settings.notifications.desktop', 'Send desktop notifications')}
describe={describe}
updateSection={handleUpdateDesktopSection}
/>
@@ -583,7 +573,7 @@ class NotificationsTab extends React.Component {
soundSection = (
<SettingItemMax
- title={formatMessage(holders.desktopSounds)}
+ title={Utils.localizeMessage('user.settings.notifications.desktopSounds', 'Desktop notification sounds')}
extraInfo={extraInfo}
inputs={inputs}
submit={this.handleSubmit}
@@ -622,7 +612,7 @@ class NotificationsTab extends React.Component {
soundSection = (
<SettingItemMin
- title={formatMessage(holders.desktopSounds)}
+ title={Utils.localizeMessage('user.settings.notifications.desktopSounds', 'Desktop notification sounds')}
describe={describe}
updateSection={handleUpdateSoundSection}
disableOpen={!this.state.soundNeeded}
@@ -630,102 +620,6 @@ class NotificationsTab extends React.Component {
);
}
- var emailSection;
- var handleUpdateEmailSection;
- if (this.props.activeSection === 'email') {
- var emailActive = [false, false];
- if (this.state.enableEmail === 'false') {
- emailActive[1] = true;
- } else {
- emailActive[0] = true;
- }
-
- let inputs = [];
-
- inputs.push(
- <div key='userNotificationEmailOptions'>
- <div className='radio'>
- <label>
- <input
- type='radio'
- name='emailNotifications'
- checked={emailActive[0]}
- onChange={this.handleEmailRadio.bind(this, 'true')}
- />
- <FormattedMessage
- id='user.settings.notifications.on'
- defaultMessage='On'
- />
- </label>
- <br/>
- </div>
- <div className='radio'>
- <label>
- <input
- type='radio'
- name='emailNotifications'
- checked={emailActive[1]}
- onChange={this.handleEmailRadio.bind(this, 'false')}
- />
- <FormattedMessage
- id='user.settings.notifications.off'
- defaultMessage='Off'
- />
- </label>
- <br/>
- </div>
- <div><br/>
- <FormattedMessage
- id='user.settings.notifications.emailInfo'
- defaultMessage='Email notifications are sent for mentions and direct messages after you’ve been offline for more than 60 seconds or away from {siteName} for more than 5 minutes.'
- values={{
- siteName: global.window.mm_config.SiteName
- }}
- />
- </div>
- </div>
- );
-
- emailSection = (
- <SettingItemMax
- title={formatMessage(holders.emailNotifications)}
- inputs={inputs}
- submit={this.handleSubmit}
- server_error={serverError}
- updateSection={this.handleCancel}
- />
- );
- } else {
- let describe = '';
- if (this.state.enableEmail === 'false') {
- describe = (
- <FormattedMessage
- id='user.settings.notifications.off'
- defaultMessage='Off'
- />
- );
- } else {
- describe = (
- <FormattedMessage
- id='user.settings.notifications.on'
- defaultMessage='On'
- />
- );
- }
-
- handleUpdateEmailSection = function updateEmailSection() {
- this.props.updateSection('email');
- }.bind(this);
-
- emailSection = (
- <SettingItemMin
- title={formatMessage(holders.emailNotifications)}
- describe={describe}
- updateSection={handleUpdateEmailSection}
- />
- );
- }
-
var keysSection;
var handleUpdateKeysSection;
if (this.props.activeSection === 'keys') {
@@ -859,7 +753,7 @@ class NotificationsTab extends React.Component {
keysSection = (
<SettingItemMax
- title={formatMessage(holders.wordsTrigger)}
+ title={Utils.localizeMessage('user.settings.notifications.wordsTrigger', 'Words that trigger mentions')}
inputs={inputs}
submit={this.handleSubmit}
server_error={serverError}
@@ -910,7 +804,7 @@ class NotificationsTab extends React.Component {
keysSection = (
<SettingItemMin
- title={formatMessage(holders.wordsTrigger)}
+ title={Utils.localizeMessage('user.settings.notifications.wordsTrigger', 'Words that trigger mentions')}
describe={describe}
updateSection={handleUpdateKeysSection}
/>
@@ -991,7 +885,7 @@ class NotificationsTab extends React.Component {
commentsSection = (
<SettingItemMax
- title={formatMessage(holders.comments)}
+ title={Utils.localizeMessage('user.settings.notifications.comments', 'Comment threads notifications')}
extraInfo={extraInfo}
inputs={inputs}
submit={this.handleSubmit}
@@ -1030,7 +924,7 @@ class NotificationsTab extends React.Component {
commentsSection = (
<SettingItemMin
- title={formatMessage(holders.comments)}
+ title={Utils.localizeMessage('user.settings.notifications.comments', 'Comment threads notifications')}
describe={describe}
updateSection={handleUpdateCommentsSection}
/>
@@ -1046,7 +940,6 @@ class NotificationsTab extends React.Component {
type='button'
className='close'
data-dismiss='modal'
- aria-label={formatMessage(holders.close)}
onClick={this.props.closeModal}
>
<span aria-hidden='true'>{'×'}</span>
@@ -1082,7 +975,14 @@ class NotificationsTab extends React.Component {
<div className='divider-light'/>
{soundSection}
<div className='divider-light'/>
- {emailSection}
+ <EmailNotificationSetting
+ activeSection={this.props.activeSection}
+ updateSection={this.props.updateSection}
+ enableEmail={this.state.enableEmail}
+ onChange={this.handleEmailRadio}
+ onSubmit={this.handleSubmit}
+ serverError={this.state.serverError}
+ />
<div className='divider-light'/>
{pushNotificationSection}
<div className='divider-light'/>
@@ -1103,7 +1003,6 @@ NotificationsTab.defaultProps = {
activeTab: ''
};
NotificationsTab.propTypes = {
- intl: intlShape.isRequired,
user: React.PropTypes.object,
updateSection: React.PropTypes.func,
updateTab: React.PropTypes.func,
@@ -1112,5 +1011,3 @@ NotificationsTab.propTypes = {
closeModal: React.PropTypes.func.isRequired,
collapseModal: React.PropTypes.func.isRequired
};
-
-export default injectIntl(NotificationsTab);
diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json
index 3922f41bb..e51d9b43a 100644
--- a/webapp/i18n/en.json
+++ b/webapp/i18n/en.json
@@ -234,6 +234,10 @@
"admin.email.easHelp": "Learn more about compiling and deploying your own mobile apps from an <a href=\"http://docs.mattermost.com/deployment/push.html#enterprise-app-store-eas\" target=\"_blank\">Enterprise App Store</a>.",
"admin.email.emailFail": "Connection unsuccessful: {error}",
"admin.email.emailSuccess": "No errors were reported while sending an email. Please check your inbox to make sure.",
+ "admin.email.enableEmailBatching.clusterEnabled": "Email batching cannot be enabled when High Availability mode is enabled.",
+ "admin.email.enableEmailBatching.siteURL": "Email batching cannot be enabled unless the SiteURL is configured in <b>Configuration > SiteURL</b>.",
+ "admin.email.enableEmailBatchingTitle": "Enable Email Batching:",
+ "admin.email.enableEmailBatchingDesc": "When true, users can have email notifications for multiple direct messages and mentions combined into a single email, configurable in <b>Account Settings > Notifications</b>.",
"admin.email.fullPushNotification": "Send full message snippet",
"admin.email.genericPushNotification": "Send generic description with user and channel names",
"admin.email.inviteSaltDescription": "32-character salt added to signing of email invites. Randomly generated on install. Click \"Regenerate\" to create new salt.",
@@ -649,7 +653,7 @@
"admin.service.sessionCacheDesc": "The number of minutes to cache a session in memory.",
"admin.service.sessionDaysEx": "Ex \"30\"",
"admin.service.siteURL": "Site URL:",
- "admin.service.siteURLDescription": "The URL, including port number and protocol, from which users will access Mattermost. Leave blank to automatically configure based on incoming traffic.",
+ "admin.service.siteURLDescription": "The URL, including post number and protocol, that users will use to access Mattermost. Leave blank to automatically configure based on incoming traffic.",
"admin.service.siteURLExample": "Ex \"https://mattermost.example.com:1234\"",
"admin.service.ssoSessionDays": "Session length SSO (days):",
"admin.service.ssoSessionDaysDesc": "The number of days from the last time a user entered their credentials to the expiry of the user's session. If the authentication method is SAML or GitLab, the user may automatically be logged back in to Mattermost if they are already logged in to SAML or GitLab. After changing this setting, the setting will take effect after the next time the user enters their credentials.",
@@ -1764,16 +1768,20 @@
"user.settings.notifications.commentsRoot": "Mention any comments on your post",
"user.settings.notifications.desktop": "Send desktop notifications",
"user.settings.notifications.desktopSounds": "Desktop notification sounds",
- "user.settings.notifications.emailInfo": "Email notifications are sent for mentions and direct messages after you’ve been offline for more than 60 seconds or away from {siteName} for more than 5 minutes.",
- "user.settings.notifications.emailNotifications": "Email notifications",
+ "user.settings.notifications.emailBatchingInfo": "Notifications are combined into a single email and sent at the maximum frequency selected here.",
+ "user.settings.notifications.emailInfo": "Email notifications that are sent for mentions and direct messages when you are offline or away from {siteName} for more than 5 minutes.",
+ "user.settings.notifications.emailNotifications": "Send email notifications",
+ "user.settings.notifications.everyXMinutes": "Every {count, plural, one {minute} other {{count, number} minutes}}",
+ "user.settings.notifications.everyHour": "Every hour",
"user.settings.notifications.header": "Notifications",
+ "user.settings.notifications.immediately": "Immediately",
"user.settings.notifications.info": "Desktop notifications are available on Firefox, Safari, Chrome, Internet Explorer, and Edge.",
"user.settings.notifications.never": "Never",
"user.settings.notifications.noWords": "No words configured",
"user.settings.notifications.off": "Off",
"user.settings.notifications.on": "On",
"user.settings.notifications.onlyMentions": "Only for mentions and direct messages",
- "user.settings.notifications.push": "Mobile push notifications",
+ "user.settings.notifications.push": "Send mobile push notifications",
"user.settings.notifications.sensitiveName": "Your case sensitive first name \"{first_name}\"",
"user.settings.notifications.sensitiveUsername": "Your non-case sensitive username \"{username}\"",
"user.settings.notifications.sensitiveWords": "Other non-case sensitive words, separated by commas:",
diff --git a/webapp/sass/routes/_admin-console.scss b/webapp/sass/routes/_admin-console.scss
index fdf4d270a..09040cc78 100644
--- a/webapp/sass/routes/_admin-console.scss
+++ b/webapp/sass/routes/_admin-console.scss
@@ -110,6 +110,7 @@
.help-text {
color: alpha-color($black, .5);
+ display: block;
margin: 10px 0 0;
&.no-margin {
diff --git a/webapp/utils/constants.jsx b/webapp/utils/constants.jsx
index 05068f4ea..8a31f6bfa 100644
--- a/webapp/utils/constants.jsx
+++ b/webapp/utils/constants.jsx
@@ -52,7 +52,9 @@ export const Preferences = {
COLLAPSE_DISPLAY_DEFAULT: 'false',
USE_MILITARY_TIME: 'use_military_time',
CATEGORY_THEME: 'theme',
- CATEGORY_FLAGGED_POST: 'flagged_post'
+ CATEGORY_FLAGGED_POST: 'flagged_post',
+ CATEGORY_NOTIFICATIONS: 'notifications',
+ EMAIL_INTERVAL: 'email_interval'
};
export const ActionTypes = keyMirror({