* PLT-3647 Added config settings for email batching

* PLT-3647 Refactored generation of email notification

* PLT-3647 Added serverside code for email batching

* PLT-3647 Updated settings UI to enable email batching

* PLT-3647 Removed debug code

* PLT-3647 Fixed 0-padding of minutes in batched notification

* PLT-3647 Updated clientside UI for when email batching is disabled

* Go fmt

* PLT-3647 Changed email batching to be disabled by default

* Updated batched email message

* Added email batching toggle to system console

* Changed Email Notifications > Immediate setting to a 30 second batch interval

* Go fmt

* Fixed link to Mattermost icon in batched email notification

* Updated users to use 30 second email batching by default

* Fully disabled email batching when clustering is enabled

* Fixed email batching setting in the system console

* Fixed casing of 'Send Email notifications' -> 'Send email notifications'

* Updating UI Improvements for email batching (#3736)

* Updated text for notification settings and SiteURL.

* Prevented enabling email batching when SiteURL isn't set in the system console

* Re-added a couple debug messages

* Added warning text when clustering is enabled
Этот коммит содержится в:
Harrison Healey
2016-08-16 14:41:47 -04:00
коммит произвёл Christopher Speller
родитель dde158c57f
Коммит 8203fd16ce
25 изменённых файлов: 1123 добавлений и 212 удалений

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

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

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

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

252
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()
}

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

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

@@ -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,42 +820,39 @@ 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)
}
ext := filepath.Ext(filename)
onlyImages = onlyImages && model.IsFileExtImage(ext)
}
filenamesString := strings.Join(filenames, ", ")
var attachmentPrefix string
if onlyImages {
attachmentPrefix = "Image"
} else {
attachmentPrefix = "File"
}
if len(post.Filenames) > 1 {
attachmentPrefix += "s"
}
bodyPage.Props["PostMessage"] = userLocale("api.post.send_notifications_and_forget.sent",
map[string]interface{}{"Prefix": attachmentPrefix, "Filenames": filenamesString})
}
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)
}
}
func getMessageForNotification(post *model.Post, translateFunc i18n.TranslateFunc) string {
if len(strings.TrimSpace(post.Message)) != 0 || len(post.Filenames) == 0 {
return post.Message
}
// 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)
}
ext := filepath.Ext(filename)
onlyImages = onlyImages && model.IsFileExtImage(ext)
}
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)
}
}
func sendPushNotification(post *model.Post, user *model.User, channel *model.Channel, senderName string, wasMentioned bool) {
var sessions []*model.Session
if result := <-Srv.Store.Session().GetSessions(user.Id); result.Err != nil {

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

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

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

@@ -110,7 +110,10 @@
"PasswordResetSalt": "vZ4DcKyVVRlKHHJpexcuXzojkE5PZ5eL",
"SendPushNotifications": false,
"PushNotificationServer": "",
"PushNotificationContents": "generic"
"PushNotificationContents": "generic",
"EnableEmailBatching": false,
"EmailBatchingBufferSize": 256,
"EmailBatchingInterval": 30
},
"RateLimitSettings": {
"EnableRateLimiter": true,

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

@@ -627,6 +627,72 @@
"id": "api.context.unknown.app_error",
"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."
@@ -1031,6 +1097,20 @@
"id": "api.post.delete_post.permissions.app_error",
"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"
@@ -2763,6 +2843,18 @@
"id": "model.compliance.is_valid.start_end_at.app_error",
"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",
@@ -2947,6 +3039,10 @@
"id": "model.config.is_valid.site_url.app_error",
"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."

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

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

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

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

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

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

43
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}}

30
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}}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -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 youve 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:",

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

@@ -110,6 +110,7 @@
.help-text {
color: alpha-color($black, .5);
display: block;
margin: 10px 0 0;
&.no-margin {

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

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