Doug Lauder
2023-03-22 17:22:27 -04:00
коммит произвёл GitHub
родитель b61c096497
Коммит c943ed6859
13276 изменённых файлов: 1695615 добавлений и 223189 удалений

1323
server/channels/app/email/email.go Обычный файл

Разница между файлами не показана из-за своего большого размера Загрузить разницу

378
server/channels/app/email/email_batching.go Обычный файл
Просмотреть файл

@@ -0,0 +1,378 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package email
import (
"bytes"
"fmt"
"html/template"
"io"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/i18n"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
)
const (
EmailBatchingTaskName = "Email Batching"
)
type postData struct {
SenderName string
ChannelName string
Message template.HTML
MessageURL string
SenderPhoto string
PostPhoto string
Time string
ShowChannelIcon bool
OtherChannelMembersCount int
MessageAttachments []*EmailMessageAttachment
}
func (es *Service) InitEmailBatching() {
if *es.config().EmailSettings.EnableEmailBatching {
if es.EmailBatching == nil {
es.EmailBatching = NewEmailBatchingJob(es, *es.config().EmailSettings.EmailBatchingBufferSize)
}
// note that we don't support changing EmailBatchingBufferSize without restarting the server
es.EmailBatching.Start()
}
}
func (es *Service) AddNotificationEmailToBatch(user *model.User, post *model.Post, team *model.Team) *model.AppError {
if !*es.config().EmailSettings.EnableEmailBatching {
return model.NewAppError("AddNotificationEmailToBatch", "api.email_batching.add_notification_email_to_batch.disabled.app_error", nil, "", http.StatusNotImplemented)
}
if !es.EmailBatching.Add(user, post, team) {
mlog.Error("Email batching job's receiving buffer was full. Please increase the EmailBatchingBufferSize. Falling back to sending immediate mail.")
return model.NewAppError("AddNotificationEmailToBatch", "api.email_batching.add_notification_email_to_batch.channel_full.app_error", nil, "", http.StatusInternalServerError)
}
return nil
}
type batchedNotification struct {
userID string
post *model.Post
teamName string
}
type EmailBatchingJob struct {
config func() *model.Config
service *Service
newNotifications chan *batchedNotification
pendingNotifications map[string][]*batchedNotification
task *model.ScheduledTask
taskMutex sync.Mutex
}
func NewEmailBatchingJob(es *Service, bufferSize int) *EmailBatchingJob {
return &EmailBatchingJob{
config: es.config,
service: es,
newNotifications: make(chan *batchedNotification, bufferSize),
pendingNotifications: make(map[string][]*batchedNotification),
}
}
func (job *EmailBatchingJob) Start() {
mlog.Debug("Email batching job starting. Checking for pending emails periodically.", mlog.Int("interval_in_seconds", *job.config().EmailSettings.EmailBatchingInterval))
newTask := model.CreateRecurringTask(EmailBatchingTaskName, job.CheckPendingEmails, time.Duration(*job.config().EmailSettings.EmailBatchingInterval)*time.Second)
job.taskMutex.Lock()
oldTask := job.task
job.task = newTask
job.taskMutex.Unlock()
if oldTask != nil {
oldTask.Cancel()
}
}
// Stop will cancel the task properly, flushing out any pending notifications.
// Although this still won't send those notifications which are yet to be sent
// due to a user's PreferenceNameEmailInterval.
func (job *EmailBatchingJob) Stop() {
job.taskMutex.Lock()
if task := job.task; task != nil {
task.Cancel()
}
job.taskMutex.Unlock()
}
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(), job.service.sendBatchedEmailNotification)
mlog.Debug("Email batching job ran. Notifications might be still pending.", mlog.Int("number_of_users", 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)) {
for userID, notifications := range job.pendingNotifications {
// Defensive code.
if len(notifications) == 0 {
mlog.Warn("Unexpected result. Got 0 pending notifications for batched email.", mlog.String("user_id", userID))
continue
}
// get how long we need to wait to send notifications to the user
var interval int64
preference, err := job.service.store.Preference().Get(userID, model.PreferenceCategoryNotifications, model.PreferenceNameEmailInterval)
if err != nil {
// use the default batching interval if an error occurs while fetching user preferences
interval, _ = strconv.ParseInt(model.PreferenceEmailIntervalBatchingSeconds, 10, 64)
} else {
if value, err := strconv.ParseInt(preference.Value, 10, 64); err != nil {
// // use the default batching interval if an error occurs while deserializing user preferences
interval, _ = strconv.ParseInt(model.PreferenceEmailIntervalBatchingSeconds, 10, 64)
} else {
interval = value
}
}
batchStartTime := notifications[0].post.CreateAt
// Ignore if it isn't time yet to send.
if now.Sub(time.UnixMilli(batchStartTime)) <= time.Duration(interval)*time.Second {
continue
}
// If the user has viewed any channels in this team since the notification was queued, delete
// all queued notifications
inspectedTeamNames := make(map[string]string)
for _, notification := range notifications {
// at most, we'll do one check for each team that notifications were sent for
if inspectedTeamNames[notification.teamName] != "" {
continue
}
team, nErr := job.service.store.Team().GetByName(notifications[0].teamName)
if nErr != nil {
mlog.Error("Unable to find Team id for notification", mlog.Err(nErr))
continue
}
if team != nil {
inspectedTeamNames[notification.teamName] = team.Id
}
channelMembers, err := job.service.store.Channel().GetMembersForUser(inspectedTeamNames[notification.teamName], userID)
if err != nil {
mlog.Error("Unable to find ChannelMembers for user", mlog.Err(err))
continue
}
deleted := false
for _, channelMember := range channelMembers {
if channelMember.LastViewedAt >= batchStartTime {
mlog.Debug("Deleted notifications for user", mlog.String("user_id", userID))
delete(job.pendingNotifications, userID)
deleted = true
break
}
}
if deleted {
break
}
}
// The notifications might have been cleared from the above step.
// We need to check again.
if len(job.pendingNotifications[userID]) == 0 {
continue
}
handler(userID, job.pendingNotifications[userID])
delete(job.pendingNotifications, userID)
}
}
/**
* If the name is longer than i characters, replace remaining characters with ...
*/
func truncateUserNames(name string, i int) string {
runes := []rune(name)
if len(runes) > i {
newString := string(runes[:i])
return newString + "..."
}
return name
}
func (es *Service) sendBatchedEmailNotification(userID string, notifications []*batchedNotification) {
user, err := es.userService.GetUser(userID)
if err != nil {
mlog.Warn("Unable to find recipient for batched email notification")
return
}
translateFunc := i18n.GetUserTranslations(user.Locale)
displayNameFormat := *es.config().TeamSettings.TeammateNameDisplay
siteURL := *es.config().ServiceSettings.SiteURL
postsData := make([]*postData, 0 /* len */, len(notifications) /* cap */)
embeddedFiles := make(map[string]io.Reader)
emailNotificationContentsType := model.EmailNotificationContentsFull
if license := es.license(); license != nil && *license.Features.EmailNotificationContents {
emailNotificationContentsType = *es.config().EmailSettings.EmailNotificationContentsType
}
// check if user has CRT set to ON
appCRT := *es.config().ServiceSettings.CollapsedThreads
threadsEnabled := appCRT == model.CollapsedThreadsAlwaysOn
if !threadsEnabled && appCRT != model.CollapsedThreadsDisabled {
threadsEnabled = appCRT == model.CollapsedThreadsDefaultOn
// check if a participant has overridden collapsed threads settings
if preference, errCrt := es.store.Preference().Get(userID, model.PreferenceCategoryDisplaySettings, model.PreferenceNameCollapsedThreadsEnabled); errCrt == nil {
threadsEnabled = preference.Value == "on"
}
}
if emailNotificationContentsType == model.EmailNotificationContentsFull {
for i, notification := range notifications {
sender, errSender := es.userService.GetUser(notification.post.UserId)
if errSender != nil {
mlog.Warn("Unable to find sender of post for batched email notification")
}
channel, errCh := es.store.Channel().Get(notification.post.ChannelId, true)
if errCh != nil {
mlog.Warn("Unable to find channel of post for batched email notification")
}
senderProfileImage, _, errProfileImage := es.userService.GetProfileImage(sender)
if errProfileImage != nil {
mlog.Warn("Unable to get the sender user profile image.", mlog.String("user_id", sender.Id), mlog.Err(errProfileImage))
}
senderPhoto := fmt.Sprintf("user-avatar-%d.png", i)
if senderProfileImage != nil {
embeddedFiles[senderPhoto] = bytes.NewReader(senderProfileImage)
}
tm := time.Unix(notification.post.CreateAt/1000, 0)
timezone, _ := tm.Zone()
t := translateFunc("api.email_batching.send_batched_email_notification.time", map[string]any{
"Hour": tm.Hour(),
"Minute": fmt.Sprintf("%02d", tm.Minute()),
"Month": translateFunc(tm.Month().String()),
"Day": tm.Day(),
"Year": tm.Year(),
"TimeZone": timezone,
})
MessageURL := siteURL + "/" + notification.teamName + "/pl/" + notification.post.Id
channelDisplayName := channel.DisplayName
showChannelIcon := true
otherChannelMembersCount := 0
if threadsEnabled && notification.post.RootId != "" {
props := map[string]any{"channelName": channelDisplayName}
channelDisplayName = translateFunc("api.push_notification.title.collapsed_threads", props)
if channel.Type == model.ChannelTypeDirect {
channelDisplayName = translateFunc("api.push_notification.title.collapsed_threads_dm")
}
}
if channel.Type == model.ChannelTypeGroup {
otherChannelMembersCount = len(strings.Split(channelDisplayName, ",")) - 1
showChannelIcon = false
channelDisplayName = truncateUserNames(channel.DisplayName, 11)
}
postsData = append(postsData, &postData{
SenderPhoto: senderPhoto,
SenderName: truncateUserNames(sender.GetDisplayName(displayNameFormat), 22),
Time: t,
ChannelName: channelDisplayName,
Message: template.HTML(es.GetMessageForNotification(notification.post, translateFunc)),
MessageURL: MessageURL,
ShowChannelIcon: showChannelIcon,
OtherChannelMembersCount: otherChannelMembersCount,
MessageAttachments: ProcessMessageAttachments(notification.post, siteURL),
})
}
}
tm := time.Unix(notifications[0].post.CreateAt/1000, 0)
subject := translateFunc("api.email_batching.send_batched_email_notification.subject", len(notifications), map[string]any{
"SiteName": es.config().TeamSettings.SiteName,
"Year": tm.Year(),
"Month": translateFunc(tm.Month().String()),
"Day": tm.Day(),
})
data := es.NewEmailTemplateData(user.Locale)
data.Props["SiteURL"] = siteURL
data.Props["Title"] = translateFunc("api.email_batching.send_batched_email_notification.title", len(notifications)-1)
data.Props["SubTitle"] = translateFunc("api.email_batching.send_batched_email_notification.subTitle")
data.Props["Button"] = translateFunc("api.email_batching.send_batched_email_notification.button")
data.Props["ButtonURL"] = siteURL
data.Props["Posts"] = postsData
data.Props["MessageButton"] = translateFunc("api.email_batching.send_batched_email_notification.messageButton")
data.Props["NotificationFooterTitle"] = translateFunc("app.notification.footer.title")
data.Props["NotificationFooterInfoLogin"] = translateFunc("app.notification.footer.infoLogin")
data.Props["NotificationFooterInfo"] = translateFunc("app.notification.footer.info")
renderedPage, renderErr := es.templatesContainer.RenderToString("messages_notification", data)
if renderErr != nil {
mlog.Error("Unable to render email", mlog.Err(renderErr))
}
if nErr := es.SendMailWithEmbeddedFiles(user.Email, subject, renderedPage, embeddedFiles, "", "", "", "BatchedEmailNotification"); nErr != nil {
mlog.Warn("Unable to send batched email notification", mlog.String("email", user.Email), mlog.Err(nErr))
}
}

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

@@ -0,0 +1,285 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package email
import (
"context"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost-server/v6/model"
)
func TestHandleNewNotifications(t *testing.T) {
th := SetupWithStoreMock(t)
defer th.TearDown()
id1 := model.NewId()
id2 := model.NewId()
id3 := model.NewId()
// test queueing of received posts by user
job := NewEmailBatchingJob(th.service, 128)
job.handleNewNotifications()
require.Empty(t, job.pendingNotifications, "shouldn't have added any pending notifications")
job.Add(&model.User{Id: id1}, &model.Post{UserId: id1, Message: "test"}, &model.Team{Name: "team"})
require.Empty(t, job.pendingNotifications, "shouldn't have added any pending notifications")
job.handleNewNotifications()
require.Len(t, job.pendingNotifications, 1, "should have received posts for 1 user")
require.Len(t, job.pendingNotifications[id1], 1, "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()
require.Len(t, job.pendingNotifications, 1, "should have received posts for 1 user")
require.Len(t, job.pendingNotifications[id1], 2, "should have received 2 posts for user1")
job.Add(&model.User{Id: id2}, &model.Post{UserId: id1, Message: "test"}, &model.Team{Name: "team"})
job.handleNewNotifications()
require.Len(t, job.pendingNotifications, 2, "should have received posts for 2 users")
require.Len(t, job.pendingNotifications[id1], 2, "should have received 2 posts for user1")
require.Len(t, job.pendingNotifications[id2], 1, "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()
require.Len(t, job.pendingNotifications, 3, "should have received posts for 3 users")
require.Len(t, job.pendingNotifications[id1], 3, "should have received 3 posts for user1")
require.Len(t, job.pendingNotifications[id2], 3, "should have received 3 posts for user2")
require.Len(t, job.pendingNotifications[id3], 1, "should have received 1 post for user3")
// test ordering of received posts
job = NewEmailBatchingJob(th.service, 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()
assert.Equal(t, job.pendingNotifications[id1][0].post.Message, "test1", "incorrect order of received posts for user1")
assert.Equal(t, job.pendingNotifications[id1][1].post.Message, "test2", "incorrect order of received posts for user1")
assert.Equal(t, job.pendingNotifications[id1][2].post.Message, "test4", "incorrect order of received posts for user1")
assert.Equal(t, job.pendingNotifications[id2][0].post.Message, "test3", "incorrect order of received posts for user2")
assert.Equal(t, job.pendingNotifications[id2][1].post.Message, "test5", "incorrect order of received posts for user2")
}
func TestCheckPendingNotifications(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
job := NewEmailBatchingJob(th.service, 128)
job.pendingNotifications[th.BasicUser.Id] = []*batchedNotification{
{
post: &model.Post{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
CreateAt: 10000000,
},
teamName: th.BasicTeam.Name,
},
}
channelMember, err := th.store.Channel().GetMember(context.Background(), th.BasicChannel.Id, th.BasicUser.Id)
require.NoError(t, err)
channelMember.LastViewedAt = 9999999
_, err = th.store.Channel().UpdateMember(channelMember)
require.NoError(t, err)
nErr := th.store.Preference().Save(model.Preferences{{
UserId: th.BasicUser.Id,
Category: model.PreferenceCategoryNotifications,
Name: model.PreferenceNameEmailInterval,
Value: "60",
}})
require.NoError(t, nErr)
// test that notifications aren't sent before interval
job.checkPendingNotifications(time.Unix(10001, 0), func(string, []*batchedNotification) {})
require.NotNil(t, job.pendingNotifications[th.BasicUser.Id])
require.Len(t, job.pendingNotifications[th.BasicUser.Id], 1, "shouldn't have sent queued post")
// test that notifications are cleared if the user has acted
channelMember, err = th.store.Channel().GetMember(context.Background(), th.BasicChannel.Id, th.BasicUser.Id)
require.NoError(t, err)
channelMember.LastViewedAt = 10001000
_, err = th.store.Channel().UpdateMember(channelMember)
require.NoError(t, err)
// We reset the interval to something shorter
nErr = th.store.Preference().Save(model.Preferences{{
UserId: th.BasicUser.Id,
Category: model.PreferenceCategoryNotifications,
Name: model.PreferenceNameEmailInterval,
Value: "10",
}})
require.NoError(t, nErr)
var wasCalled int32
job.checkPendingNotifications(time.Unix(10050, 0), func(string, []*batchedNotification) {
atomic.StoreInt32(&wasCalled, int32(1))
})
// A hack to check whether the handler was called.
// It's not straightforward to just wait for it using a channel because the test should
// NOT call the handler, and it will be called only if the test fails.
time.Sleep(1 * time.Second)
// We do a check outside the email handler, because otherwise, failing from
// inside the handler doesn't let the .Go() function exit cleanly, and it gets
// stuck during server shutdown, trying to wait for the goroutine to exit
require.Equal(t, int32(0), atomic.LoadInt32(&wasCalled), "email handler should not have been called")
require.Nil(t, job.pendingNotifications[th.BasicUser.Id])
require.Empty(t, job.pendingNotifications[th.BasicUser.Id], "should've remove queued post since user acted")
// test that notifications are sent if enough time passes since the first message
job.pendingNotifications[th.BasicUser.Id] = []*batchedNotification{
{
post: &model.Post{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
CreateAt: 10060000,
Message: "post1",
},
teamName: th.BasicTeam.Name,
},
{
post: &model.Post{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
CreateAt: 10090000,
Message: "post2",
},
teamName: th.BasicTeam.Name,
},
}
received := make(chan *model.Post, 2)
job.checkPendingNotifications(time.Unix(10130, 0), func(s string, notifications []*batchedNotification) {
for _, notification := range notifications {
received <- notification.post
}
})
require.Nil(t, job.pendingNotifications[th.BasicUser.Id], "shouldn't have sent queued post")
select {
case post := <-received:
require.Equal(t, post.Message, "post1", "should've received post1 first")
case <-time.After(5 * time.Second):
require.Fail(t, "timed out waiting for first post notification")
}
select {
case post := <-received:
require.Equal(t, post.Message, "post2", "should've received post2 second")
case <-time.After(5 * time.Second):
require.Fail(t, "timed out waiting for second post notification")
}
}
/**
* Ensures that email batch interval defaults to 15 minutes for users that haven't explicitly set this preference
*/
func TestCheckPendingNotificationsDefaultInterval(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
job := NewEmailBatchingJob(th.service, 128)
// bypasses recent user activity check
require.NotNil(t, th.store)
require.NotNil(t, th.store.Channel())
require.NotNil(t, th.BasicUser)
require.NotNil(t, th.BasicChannel)
channelMember, err := th.store.Channel().GetMember(context.Background(), th.BasicChannel.Id, th.BasicUser.Id)
require.NoError(t, err)
channelMember.LastViewedAt = 9999000
_, err = th.store.Channel().UpdateMember(channelMember)
require.NoError(t, err)
job.pendingNotifications[th.BasicUser.Id] = []*batchedNotification{
{
post: &model.Post{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
CreateAt: 10000000,
},
teamName: th.BasicTeam.Name,
},
}
// notifications should not be sent 1s after post was created, because default batch interval is 15mins
job.checkPendingNotifications(time.Unix(10001, 0), func(string, []*batchedNotification) {})
require.NotNil(t, job.pendingNotifications[th.BasicUser.Id])
require.Len(t, job.pendingNotifications[th.BasicUser.Id], 1, "shouldn't have sent queued post")
// notifications should be sent 901s after post was created, because default batch interval is 15mins
job.checkPendingNotifications(time.Unix(10901, 0), func(string, []*batchedNotification) {})
require.Nil(t, job.pendingNotifications[th.BasicUser.Id])
require.Empty(t, job.pendingNotifications[th.BasicUser.Id], "should have sent queued post")
}
/**
* Ensures that email batch interval defaults to 15 minutes if user preference is invalid
*/
func TestCheckPendingNotificationsCantParseInterval(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
job := NewEmailBatchingJob(th.service, 128)
require.NotNil(t, th.store)
require.NotNil(t, th.store.Channel())
require.NotNil(t, th.BasicChannel)
require.NotNil(t, th.BasicUser)
// bypasses recent user activity check
channelMember, err := th.store.Channel().GetMember(context.Background(), th.BasicChannel.Id, th.BasicUser.Id)
require.NoError(t, err)
channelMember.LastViewedAt = 9999000
_, err = th.store.Channel().UpdateMember(channelMember)
require.NoError(t, err)
// preference value is not an integer, so we'll fall back to the default 15min value
nErr := th.store.Preference().Save(model.Preferences{{
UserId: th.BasicUser.Id,
Category: model.PreferenceCategoryNotifications,
Name: model.PreferenceNameEmailInterval,
Value: "notAnIntegerValue",
}})
require.NoError(t, nErr)
job.pendingNotifications[th.BasicUser.Id] = []*batchedNotification{
{
post: &model.Post{
UserId: th.BasicUser.Id,
ChannelId: th.BasicChannel.Id,
CreateAt: 10000000,
},
teamName: th.BasicTeam.Name,
},
}
// notifications should not be sent 1s after post was created, because default batch interval is 15mins
job.checkPendingNotifications(time.Unix(10001, 0), func(string, []*batchedNotification) {})
require.NotNil(t, job.pendingNotifications[th.BasicUser.Id])
require.Len(t, job.pendingNotifications[th.BasicUser.Id], 1, "shouldn't have sent queued post")
// notifications should be sent 901s after post was created, because default batch interval is 15mins
job.checkPendingNotifications(time.Unix(10901, 0), func(string, []*batchedNotification) {})
require.Nil(t, job.pendingNotifications[th.BasicUser.Id], "should have sent queued post")
}

426
server/channels/app/email/email_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,426 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package email
import (
"bytes"
"io"
"os"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mail"
)
func TestCondenseSiteURL(t *testing.T) {
require.Equal(t, "", condenseSiteURL(""))
require.Equal(t, "mattermost.com", condenseSiteURL("mattermost.com"))
require.Equal(t, "mattermost.com", condenseSiteURL("mattermost.com/"))
require.Equal(t, "chat.mattermost.com", condenseSiteURL("chat.mattermost.com"))
require.Equal(t, "chat.mattermost.com", condenseSiteURL("chat.mattermost.com/"))
require.Equal(t, "mattermost.com/subpath", condenseSiteURL("mattermost.com/subpath"))
require.Equal(t, "mattermost.com/subpath", condenseSiteURL("mattermost.com/subpath/"))
require.Equal(t, "chat.mattermost.com/subpath", condenseSiteURL("chat.mattermost.com/subpath"))
require.Equal(t, "chat.mattermost.com/subpath", condenseSiteURL("chat.mattermost.com/subpath/"))
require.Equal(t, "mattermost.com:8080", condenseSiteURL("http://mattermost.com:8080"))
require.Equal(t, "mattermost.com:8080", condenseSiteURL("http://mattermost.com:8080/"))
require.Equal(t, "chat.mattermost.com:8080", condenseSiteURL("http://chat.mattermost.com:8080"))
require.Equal(t, "chat.mattermost.com:8080", condenseSiteURL("http://chat.mattermost.com:8080/"))
require.Equal(t, "mattermost.com:8080/subpath", condenseSiteURL("http://mattermost.com:8080/subpath"))
require.Equal(t, "mattermost.com:8080/subpath", condenseSiteURL("http://mattermost.com:8080/subpath/"))
require.Equal(t, "chat.mattermost.com:8080/subpath", condenseSiteURL("http://chat.mattermost.com:8080/subpath"))
require.Equal(t, "chat.mattermost.com:8080/subpath", condenseSiteURL("http://chat.mattermost.com:8080/subpath/"))
}
func TestSendInviteEmails(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
th.ConfigureInbucketMail()
emailTo := "test@example.com"
retrieveEmail := func(t *testing.T) mail.JSONMessageInbucket {
t.Helper()
var resultsMailbox mail.JSONMessageHeaderInbucket
err2 := mail.RetryInbucket(5, func() error {
var err error
resultsMailbox, err = mail.GetMailBox(emailTo)
return err
})
if err2 != nil {
t.Skipf("No email was received, maybe due load on the server: %v", err2)
}
require.Len(t, resultsMailbox, 1)
require.Contains(t, resultsMailbox[0].To[0], emailTo, "Wrong To: recipient")
resultsEmail, err := mail.GetMessageFromMailbox(emailTo, resultsMailbox[0].ID)
require.NoError(t, err, "Could not get message from mailbox")
return resultsEmail
}
verifyMailbox := func(t *testing.T) {
t.Helper()
email := retrieveEmail(t)
require.Contains(t, email.Body.HTML, "http://testserver", "Wrong received message %s", email.Body.Text)
require.Contains(t, email.Body.HTML, "test-user", "Wrong received message %s", email.Body.Text)
require.Contains(t, email.Body.Text, "http://testserver", "Wrong received message %s", email.Body.Text)
require.Contains(t, email.Body.Text, "test-user", "Wrong received message %s", email.Body.Text)
}
th.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.EnableEmailInvitations = true
*cfg.EmailSettings.SendEmailNotifications = false
})
t.Run("SendInviteEmails", func(t *testing.T) {
mail.DeleteMailBox(emailTo)
err := th.service.SendInviteEmails(th.BasicTeam, "test-user", th.BasicUser.Id, []string{emailTo}, "http://testserver", nil, false, false, false)
require.NoError(t, err)
verifyMailbox(t)
})
t.Run("SendInviteEmails can return error when SMTP connection fails", func(t *testing.T) {
originalPort := *th.service.config().EmailSettings.SMTPPort
th.UpdateConfig(func(cfg *model.Config) {
os.Setenv("MM_EMAILSETTINGS_SMTPPORT", "5432")
*cfg.EmailSettings.SMTPPort = "5432"
})
defer th.UpdateConfig(func(cfg *model.Config) {
os.Setenv("MM_EMAILSETTINGS_SMTPPORT", originalPort)
*cfg.EmailSettings.SMTPPort = originalPort
})
err := th.service.SendInviteEmails(th.BasicTeam, "test-user", th.BasicUser.Id, []string{emailTo}, "http://testserver", nil, true, false, false)
require.Error(t, err)
err = th.service.SendInviteEmails(th.BasicTeam, "test-user", th.BasicUser.Id, []string{emailTo}, "http://testserver", nil, false, false, false)
require.NoError(t, err)
})
t.Run("SendGuestInviteEmails", func(t *testing.T) {
mail.DeleteMailBox(emailTo)
err := th.service.SendGuestInviteEmails(
th.BasicTeam,
[]*model.Channel{th.BasicChannel},
"test-user",
th.BasicUser.Id,
nil,
[]string{emailTo},
"http://testserver",
"hello world",
false,
false,
false,
)
require.NoError(t, err)
verifyMailbox(t)
})
t.Run("SendGuestInviteEmail can return error when SMTP connection fails", func(t *testing.T) {
originalPort := *th.service.config().EmailSettings.SMTPPort
th.UpdateConfig(func(cfg *model.Config) {
os.Setenv("MM_EMAILSETTINGS_SMTPPORT", "5432")
*cfg.EmailSettings.SMTPPort = "5432"
})
defer th.UpdateConfig(func(cfg *model.Config) {
os.Setenv("MM_EMAILSETTINGS_SMTPPORT", originalPort)
*cfg.EmailSettings.SMTPPort = originalPort
})
err := th.service.SendGuestInviteEmails(
th.BasicTeam,
[]*model.Channel{th.BasicChannel},
"test-user",
th.BasicUser.Id,
nil,
[]string{emailTo},
"http://testserver",
"hello world",
false,
false,
false,
)
require.NoError(t, err)
err = th.service.SendGuestInviteEmails(
th.BasicTeam,
[]*model.Channel{th.BasicChannel},
"test-user",
th.BasicUser.Id,
nil,
[]string{emailTo},
"http://testserver",
"hello world",
true,
false,
false,
)
require.Error(t, err)
})
t.Run("SendGuestInviteEmails should sanitize HTML input", func(t *testing.T) {
mail.DeleteMailBox(emailTo)
message := `<a href="http://testserver">sanitized message</a>`
err := th.service.SendGuestInviteEmails(
th.BasicTeam,
[]*model.Channel{th.BasicChannel},
"test-user",
th.BasicUser.Id,
nil,
[]string{emailTo},
"http://testserver",
message,
false,
false,
false,
)
require.NoError(t, err)
email := retrieveEmail(t)
require.NotContains(t, email.Body.HTML, message)
require.Contains(t, email.Body.HTML, "sanitized message")
require.Contains(t, email.Body.Text, "sanitized message")
})
t.Run("SendInviteEmails should contain button URL with 'started by role' param for system user", func(t *testing.T) {
mail.DeleteMailBox(emailTo)
err := th.service.SendInviteEmails(
th.BasicTeam,
"test-user",
th.BasicUser.Id,
[]string{emailTo},
"http://testserver",
nil,
false,
false,
false,
)
require.NoError(t, err)
email := retrieveEmail(t)
require.Contains(t, email.Body.HTML, "&amp;sbr=su")
})
t.Run("SendInviteEmails should contain button URL with 'started by role' param for system admin", func(t *testing.T) {
mail.DeleteMailBox(emailTo)
err := th.service.SendInviteEmails(
th.BasicTeam,
"test-user",
th.BasicUser.Id,
[]string{emailTo},
"http://testserver",
nil,
false,
true,
false,
)
require.NoError(t, err)
email := retrieveEmail(t)
require.Contains(t, email.Body.HTML, "&amp;sbr=sa")
})
t.Run("SendInviteEmails should contain button URL with 'started by role' param for first system admin", func(t *testing.T) {
mail.DeleteMailBox(emailTo)
err := th.service.SendInviteEmails(
th.BasicTeam,
"test-user",
th.BasicUser.Id,
[]string{emailTo},
"http://testserver",
nil,
false,
true,
true,
)
require.NoError(t, err)
email := retrieveEmail(t)
require.Contains(t, email.Body.HTML, "&amp;sbr=fa")
})
}
func TestSendCloudUpgradedEmail(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
th.ConfigureInbucketMail()
emailTo := "testclouduser@example.com"
emailToUsername := strings.Split(emailTo, "@")[0]
t.Run("SendCloudMonthlyUpgradedEmail", func(t *testing.T) {
verifyMailbox := func(t *testing.T) {
t.Helper()
var resultsMailbox mail.JSONMessageHeaderInbucket
err2 := mail.RetryInbucket(5, func() error {
var err error
resultsMailbox, err = mail.GetMailBox(emailTo)
return err
})
if err2 != nil {
t.Skipf("No email was received, maybe due load on the server: %v", err2)
}
require.Len(t, resultsMailbox, 1)
require.Contains(t, resultsMailbox[0].To[0], emailTo, "Wrong To: recipient")
resultsEmail, err := mail.GetMessageFromMailbox(emailTo, resultsMailbox[0].ID)
require.NoError(t, err, "Could not get message from mailbox")
require.Contains(t, resultsEmail.Body.Text, "You are now upgraded!", "Wrong received message %s", resultsEmail.Body.Text)
require.Contains(t, resultsEmail.Body.Text, "SomeName workspace has now been upgraded", "Wrong received message %s", resultsEmail.Body.Text)
require.Contains(t, resultsEmail.Body.Text, "You'll be billed from", "Wrong received message %s", resultsEmail.Body.Text)
require.Contains(t, resultsEmail.Body.Text, "Open Mattermost", "Wrong received message %s", resultsEmail.Body.Text)
require.Len(t, resultsEmail.Attachments, 0)
}
mail.DeleteMailBox(emailTo)
// Send Update to Monthly Plan email
err := th.service.SendCloudUpgradeConfirmationEmail(emailTo, emailToUsername, "June 23, 2200", th.BasicUser.Locale, "https://example.com", "SomeName", false, make(map[string]io.Reader))
require.NoError(t, err)
verifyMailbox(t)
})
t.Run("SendCloudYearlyUpgradedEmail", func(t *testing.T) {
verifyMailbox := func(t *testing.T) {
t.Helper()
var resultsMailbox mail.JSONMessageHeaderInbucket
err2 := mail.RetryInbucket(5, func() error {
var err error
resultsMailbox, err = mail.GetMailBox(emailTo)
return err
})
if err2 != nil {
t.Skipf("No email was received, maybe due load on the server: %v", err2)
}
require.Len(t, resultsMailbox, 1)
require.Contains(t, resultsMailbox[0].To[0], emailTo, "Wrong To: recipient")
resultsEmail, err := mail.GetMessageFromMailbox(emailTo, resultsMailbox[0].ID)
require.NoError(t, err, "Could not get message from mailbox")
require.Contains(t, resultsEmail.Body.Text, "You are now upgraded!", "Wrong received message %s", resultsEmail.Body.Text)
require.Contains(t, resultsEmail.Body.Text, "SomeName workspace has now been upgraded", "Wrong received message %s", resultsEmail.Body.Text)
require.Contains(t, resultsEmail.Body.Text, "View your invoice", "Wrong received message %s", resultsEmail.Body.Text)
require.Len(t, resultsEmail.Attachments, 1)
}
mail.DeleteMailBox(emailTo)
// Send Update to Monthly Plan email
var embeddedFiles = map[string]io.Reader{
"filename": bytes.NewReader([]byte("Test")),
}
err := th.service.SendCloudUpgradeConfirmationEmail(emailTo, emailToUsername, "June 23, 2200", th.BasicUser.Locale, "https://example.com", "SomeName", true, embeddedFiles)
require.NoError(t, err)
verifyMailbox(t)
})
}
func TestSendCloudWelcomeEmail(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
th.ConfigureInbucketMail()
emailTo := "testclouduser@example.com"
t.Run("TestSendCloudWelcomeEmail", func(t *testing.T) {
verifyMailbox := func(t *testing.T) {
t.Helper()
var resultsMailbox mail.JSONMessageHeaderInbucket
err2 := mail.RetryInbucket(5, func() error {
var err error
resultsMailbox, err = mail.GetMailBox(emailTo)
return err
})
if err2 != nil {
t.Skipf("No email was received, maybe due load on the server: %v", err2)
}
require.Len(t, resultsMailbox, 1)
require.Contains(t, resultsMailbox[0].To[0], emailTo, "Wrong To: recipient")
resultsEmail, err := mail.GetMessageFromMailbox(emailTo, resultsMailbox[0].ID)
require.NoError(t, err, "Could not get message from mailbox")
require.Contains(t, resultsEmail.Subject, "Congratulations!", "Wrong subject message %s", resultsEmail.Subject)
require.Contains(t, resultsEmail.Body.Text, "Your workspace is ready to go!", "Wrong body %s", resultsEmail.Body.Text)
}
mail.DeleteMailBox(emailTo)
err := th.service.SendCloudWelcomeEmail(emailTo, th.BasicUser.Locale, "inviteID", "SomeName", "example.com", "https://example.com")
require.NoError(t, err)
verifyMailbox(t)
})
}
func TestMailServiceConfig(t *testing.T) {
configuredReplyTo := "feedbackexample@test.com"
customReplyTo := "customreplyto@test.com"
emailService := Service{
config: func() *model.Config {
return &model.Config{
ServiceSettings: model.ServiceSettings{
SiteURL: model.NewString(""),
},
EmailSettings: model.EmailSettings{
EnableSignUpWithEmail: new(bool),
EnableSignInWithEmail: new(bool),
EnableSignInWithUsername: new(bool),
SendEmailNotifications: new(bool),
UseChannelInEmailNotifications: new(bool),
RequireEmailVerification: new(bool),
FeedbackName: new(string),
FeedbackEmail: new(string),
ReplyToAddress: model.NewString(configuredReplyTo),
FeedbackOrganization: new(string),
EnableSMTPAuth: new(bool),
SMTPUsername: new(string),
SMTPPassword: new(string),
SMTPServer: new(string),
SMTPPort: new(string),
SMTPServerTimeout: new(int),
ConnectionSecurity: new(string),
SendPushNotifications: new(bool),
PushNotificationServer: new(string),
PushNotificationContents: new(string),
PushNotificationBuffer: new(int),
EnableEmailBatching: new(bool),
EmailBatchingBufferSize: new(int),
EmailBatchingInterval: new(int),
EnablePreviewModeBanner: new(bool),
SkipServerCertificateVerification: new(bool),
EmailNotificationContentsType: new(string),
LoginButtonColor: new(string),
LoginButtonBorderColor: new(string),
LoginButtonTextColor: new(string),
EnableInactivityEmail: new(bool),
},
}
},
}
t.Run("use custom replyto instead of configured replyto", func(t *testing.T) {
mailConfig := emailService.mailServiceConfig(customReplyTo)
require.Equal(t, customReplyTo, mailConfig.ReplyToAddress)
})
t.Run("use configured replyto", func(t *testing.T) {
mailConfig := emailService.mailServiceConfig("")
require.Equal(t, configuredReplyTo, mailConfig.ReplyToAddress)
})
}

14
server/channels/app/email/errors.go Обычный файл
Просмотреть файл

@@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package email
import "github.com/pkg/errors"
var (
CreateEmailTokenError = errors.New("could not create token")
NoRateLimiterError = errors.New("the rate limit could not be found")
SetupRateLimiterError = errors.New("the rate limiter could not be set")
RateLimitExceededError = errors.New("the rate limit is exceeded")
SendMailError = errors.New("could not send the email")
)

300
server/channels/app/email/helper_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,300 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package email
import (
"bytes"
"os"
"path/filepath"
"testing"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/plugin/plugintest/mock"
"github.com/mattermost/mattermost-server/v6/server/channels/app/users"
"github.com/mattermost/mattermost-server/v6/server/channels/store"
"github.com/mattermost/mattermost-server/v6/server/channels/store/storetest/mocks"
"github.com/mattermost/mattermost-server/v6/server/channels/testlib"
"github.com/mattermost/mattermost-server/v6/server/config"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/templates"
)
type TestHelper struct {
service *Service
configStore *config.Store
store store.Store
workspace string
BasicTeam *model.Team
BasicChannel *model.Channel
BasicUser *model.User
BasicUser2 *model.User
SystemAdminUser *model.User
LogBuffer *bytes.Buffer
}
func Setup(tb testing.TB) *TestHelper {
if testing.Short() {
tb.SkipNow()
}
dbStore := mainHelper.GetStore()
dbStore.DropAllTables()
dbStore.MarkSystemRanUnitTests()
mainHelper.PreloadMigrations()
return setupTestHelper(dbStore, tb)
}
func SetupWithStoreMock(tb testing.TB) *TestHelper {
mockStore := testlib.GetMockStoreForSetupFunctions()
th := setupTestHelper(mockStore, tb)
statusMock := mocks.StatusStore{}
statusMock.On("UpdateExpiredDNDStatuses").Return([]*model.Status{}, nil)
statusMock.On("Get", "user1").Return(&model.Status{UserId: "user1", Status: model.StatusOnline}, nil)
statusMock.On("UpdateLastActivityAt", "user1", mock.Anything).Return(nil)
statusMock.On("SaveOrUpdate", mock.AnythingOfType("*model.Status")).Return(nil)
emptyMockStore := mocks.Store{}
emptyMockStore.On("Close").Return(nil)
emptyMockStore.On("Status").Return(&statusMock)
th.service.store = &emptyMockStore
return th
}
func setupTestHelper(s store.Store, tb testing.TB) *TestHelper {
tempWorkspace, err := os.MkdirTemp("", "userservicetest")
if err != nil {
panic(err)
}
configStore := config.NewTestMemoryStore()
config := configStore.Get()
*config.PluginSettings.Directory = filepath.Join(tempWorkspace, "plugins")
*config.PluginSettings.ClientDirectory = filepath.Join(tempWorkspace, "webapp")
*config.PluginSettings.AutomaticPrepackagedPlugins = false
*config.LogSettings.EnableSentry = false // disable error reporting during tests
*config.AnnouncementSettings.AdminNoticesEnabled = false
*config.AnnouncementSettings.UserNoticesEnabled = false
*config.TeamSettings.MaxUsersPerTeam = 50
*config.RateLimitSettings.Enable = false
*config.TeamSettings.EnableOpenServer = true
// Disable strict password requirements for test
*config.PasswordSettings.MinimumLength = 5
*config.PasswordSettings.Lowercase = false
*config.PasswordSettings.Uppercase = false
*config.PasswordSettings.Symbol = false
*config.PasswordSettings.Number = false
configStore.Set(config)
licenseFn := func() *model.License { return model.NewTestLicense() }
us, err := users.New(users.ServiceConfig{
UserStore: s.User(),
SessionStore: s.Session(),
OAuthStore: s.OAuth(),
ConfigFn: configStore.Get,
LicenseFn: licenseFn,
})
if err != nil {
panic(err)
}
templatesDir, ok := templates.GetTemplateDirectory()
if !ok {
panic("failed find server templates")
}
htmlTemplateWatcher, errorsChan, err := templates.NewWithWatcher(templatesDir)
if err != nil {
panic(err)
}
go func() {
for err2 := range errorsChan {
mlog.Error("Server templates error", mlog.Err(err2))
}
}()
service := &Service{
store: s,
userService: us,
license: licenseFn,
config: configStore.Get,
templatesContainer: htmlTemplateWatcher,
}
if err := service.setUpRateLimiters(); err != nil {
panic(err)
}
return &TestHelper{
service: service,
configStore: configStore,
store: s,
LogBuffer: &bytes.Buffer{},
workspace: tempWorkspace,
}
}
func (th *TestHelper) InitBasic() *TestHelper {
th.BasicTeam = th.CreateTeam()
th.SystemAdminUser = th.CreateUser()
th.SystemAdminUser, _ = th.service.userService.GetUser(th.SystemAdminUser.Id)
th.addUserToTeam(th.BasicTeam, th.SystemAdminUser)
th.BasicUser = th.CreateUser()
th.BasicUser, _ = th.service.userService.GetUser(th.BasicUser.Id)
th.addUserToTeam(th.BasicTeam, th.BasicUser)
th.BasicUser2 = th.CreateUser()
th.BasicUser2, _ = th.service.userService.GetUser(th.BasicUser2.Id)
th.addUserToTeam(th.BasicTeam, th.BasicUser2)
th.BasicChannel = th.createChannel(th.BasicTeam, string(model.ChannelTypeOpen))
th.addUserToChannel(th.BasicChannel, th.SystemAdminUser)
th.addUserToChannel(th.BasicChannel, th.BasicUser)
th.addUserToChannel(th.BasicChannel, th.BasicUser2)
return th
}
func (th *TestHelper) CreateTeam() *model.Team {
id := model.NewId()
team := &model.Team{
DisplayName: "dn_" + id,
Name: "name" + id,
Email: "success+" + id + "@simulator.amazonses.com",
Type: model.TeamOpen,
}
var err error
if team, err = th.store.Team().Save(team); err != nil {
panic(err)
}
return team
}
func (th *TestHelper) createChannel(team *model.Team, channelType string) *model.Channel {
id := model.NewId()
channel := &model.Channel{
DisplayName: "dn_" + id,
Name: "name_" + id,
Type: model.ChannelType(channelType),
TeamId: team.Id,
CreatorId: th.BasicUser.Id,
}
var err error
if channel, err = th.store.Channel().Save(channel, *th.configStore.Get().TeamSettings.MaxChannelsPerTeam); err != nil {
panic(err)
}
return channel
}
func (th *TestHelper) addUserToChannel(channel *model.Channel, user *model.User) *model.ChannelMember {
newMember := &model.ChannelMember{
ChannelId: channel.Id,
UserId: user.Id,
NotifyProps: model.GetDefaultChannelNotifyProps(),
SchemeGuest: user.IsGuest(),
SchemeUser: !user.IsGuest(),
}
var err error
newMember, err = th.store.Channel().SaveMember(newMember)
if err != nil {
panic(err)
}
return newMember
}
func (th *TestHelper) addUserToTeam(team *model.Team, user *model.User) *model.TeamMember {
tm := &model.TeamMember{
TeamId: team.Id,
UserId: user.Id,
SchemeGuest: user.IsGuest(),
SchemeUser: !user.IsGuest(),
}
var err error
tm, err = th.store.Team().SaveMember(tm, *th.service.config().TeamSettings.MaxUsersPerTeam)
if err != nil {
panic(err)
}
return tm
}
func (th *TestHelper) CreateUser() *model.User {
return th.CreateUserOrGuest(false)
}
func (th *TestHelper) CreateGuest() *model.User {
return th.CreateUserOrGuest(true)
}
func (th *TestHelper) CreateUserOrGuest(guest bool) *model.User {
id := model.NewId()
user := &model.User{
Email: "success+" + id + "@simulator.amazonses.com",
Username: "un_" + id,
Nickname: "nn_" + id,
Password: "Password1",
EmailVerified: true,
}
var err error
if guest {
if user, err = th.service.userService.CreateUser(user, users.UserCreateOptions{Guest: true}); err != nil {
panic(err)
}
} else {
if user, err = th.service.userService.CreateUser(user, users.UserCreateOptions{}); err != nil {
panic(err)
}
}
return user
}
func (th *TestHelper) TearDown() {
th.configStore.Close()
th.store.Close()
if th.workspace != "" {
os.RemoveAll(th.workspace)
}
}
func (th *TestHelper) UpdateConfig(f func(*model.Config)) {
if th.configStore.IsReadOnly() {
return
}
old := th.configStore.Get()
updated := old.Clone()
f(updated)
if _, _, err := th.configStore.Set(updated); err != nil {
panic(err)
}
}
func (th *TestHelper) ConfigureInbucketMail() {
inbucket_host := os.Getenv("CI_INBUCKET_HOST")
if inbucket_host == "" {
inbucket_host = "localhost"
}
inbucket_port := os.Getenv("CI_INBUCKET_SMTP_PORT")
if inbucket_port == "" {
inbucket_port = "10025"
}
th.UpdateConfig(func(cfg *model.Config) {
*cfg.EmailSettings.SMTPServer = inbucket_host
*cfg.EmailSettings.SMTPPort = inbucket_port
})
}

32
server/channels/app/email/main_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,32 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package email
import (
"flag"
"testing"
"github.com/mattermost/mattermost-server/v6/server/channels/testlib"
)
var mainHelper *testlib.MainHelper
var replicaFlag bool
func TestMain(m *testing.M) {
if f := flag.Lookup("mysql-replica"); f == nil {
flag.BoolVar(&replicaFlag, "mysql-replica", false, "")
flag.Parse()
}
var options = testlib.HelperOptions{
EnableStore: true,
EnableResources: true,
WithReadReplica: replicaFlag,
}
mainHelper = testlib.NewMainHelperWithOptions(&options)
defer mainHelper.Close()
mainHelper.Main(m)
}

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

@@ -0,0 +1,560 @@
// Code generated by mockery v2.10.4. DO NOT EDIT.
// Regenerate this file using `make email-mocks`.
package mocks
import (
io "io"
i18n "github.com/mattermost/mattermost-server/v6/server/platform/shared/i18n"
mock "github.com/stretchr/testify/mock"
model "github.com/mattermost/mattermost-server/v6/model"
templates "github.com/mattermost/mattermost-server/v6/server/platform/shared/templates"
throttled "github.com/throttled/throttled"
)
// ServiceInterface is an autogenerated mock type for the ServiceInterface type
type ServiceInterface struct {
mock.Mock
}
// AddNotificationEmailToBatch provides a mock function with given fields: user, post, team
func (_m *ServiceInterface) AddNotificationEmailToBatch(user *model.User, post *model.Post, team *model.Team) *model.AppError {
ret := _m.Called(user, post, team)
var r0 *model.AppError
if rf, ok := ret.Get(0).(func(*model.User, *model.Post, *model.Team) *model.AppError); ok {
r0 = rf(user, post, team)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.AppError)
}
}
return r0
}
// CreateVerifyEmailToken provides a mock function with given fields: userID, newEmail
func (_m *ServiceInterface) CreateVerifyEmailToken(userID string, newEmail string) (*model.Token, error) {
ret := _m.Called(userID, newEmail)
var r0 *model.Token
if rf, ok := ret.Get(0).(func(string, string) *model.Token); ok {
r0 = rf(userID, newEmail)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Token)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string, string) error); ok {
r1 = rf(userID, newEmail)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetMessageForNotification provides a mock function with given fields: post, translateFunc
func (_m *ServiceInterface) GetMessageForNotification(post *model.Post, translateFunc i18n.TranslateFunc) string {
ret := _m.Called(post, translateFunc)
var r0 string
if rf, ok := ret.Get(0).(func(*model.Post, i18n.TranslateFunc) string); ok {
r0 = rf(post, translateFunc)
} else {
r0 = ret.Get(0).(string)
}
return r0
}
// GetPerDayEmailRateLimiter provides a mock function with given fields:
func (_m *ServiceInterface) GetPerDayEmailRateLimiter() *throttled.GCRARateLimiter {
ret := _m.Called()
var r0 *throttled.GCRARateLimiter
if rf, ok := ret.Get(0).(func() *throttled.GCRARateLimiter); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*throttled.GCRARateLimiter)
}
}
return r0
}
// InitEmailBatching provides a mock function with given fields:
func (_m *ServiceInterface) InitEmailBatching() {
_m.Called()
}
// NewEmailTemplateData provides a mock function with given fields: locale
func (_m *ServiceInterface) NewEmailTemplateData(locale string) templates.Data {
ret := _m.Called(locale)
var r0 templates.Data
if rf, ok := ret.Get(0).(func(string) templates.Data); ok {
r0 = rf(locale)
} else {
r0 = ret.Get(0).(templates.Data)
}
return r0
}
// SendChangeUsernameEmail provides a mock function with given fields: newUsername, _a1, locale, siteURL
func (_m *ServiceInterface) SendChangeUsernameEmail(newUsername string, _a1 string, locale string, siteURL string) error {
ret := _m.Called(newUsername, _a1, locale, siteURL)
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string, string) error); ok {
r0 = rf(newUsername, _a1, locale, siteURL)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendCloudUpgradeConfirmationEmail provides a mock function with given fields: userEmail, name, trialEndDate, locale, siteURL, workspaceName, isYearly, embeddedFiles
func (_m *ServiceInterface) SendCloudUpgradeConfirmationEmail(userEmail string, name string, trialEndDate string, locale string, siteURL string, workspaceName string, isYearly bool, embeddedFiles map[string]io.Reader) error {
ret := _m.Called(userEmail, name, trialEndDate, locale, siteURL, workspaceName, isYearly, embeddedFiles)
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string, string, string, string, bool, map[string]io.Reader) error); ok {
r0 = rf(userEmail, name, trialEndDate, locale, siteURL, workspaceName, isYearly, embeddedFiles)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendCloudWelcomeEmail provides a mock function with given fields: userEmail, locale, teamInviteID, workSpaceName, dns, siteURL
func (_m *ServiceInterface) SendCloudWelcomeEmail(userEmail string, locale string, teamInviteID string, workSpaceName string, dns string, siteURL string) error {
ret := _m.Called(userEmail, locale, teamInviteID, workSpaceName, dns, siteURL)
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string, string, string, string) error); ok {
r0 = rf(userEmail, locale, teamInviteID, workSpaceName, dns, siteURL)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendDeactivateAccountEmail provides a mock function with given fields: _a0, locale, siteURL
func (_m *ServiceInterface) SendDeactivateAccountEmail(_a0 string, locale string, siteURL string) error {
ret := _m.Called(_a0, locale, siteURL)
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string) error); ok {
r0 = rf(_a0, locale, siteURL)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendDelinquencyEmail14 provides a mock function with given fields: _a0, locale, siteURL, planName
func (_m *ServiceInterface) SendDelinquencyEmail14(_a0 string, locale string, siteURL string, planName string) error {
ret := _m.Called(_a0, locale, siteURL, planName)
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string, string) error); ok {
r0 = rf(_a0, locale, siteURL, planName)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendDelinquencyEmail30 provides a mock function with given fields: _a0, locale, siteURL, planName
func (_m *ServiceInterface) SendDelinquencyEmail30(_a0 string, locale string, siteURL string, planName string) error {
ret := _m.Called(_a0, locale, siteURL, planName)
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string, string) error); ok {
r0 = rf(_a0, locale, siteURL, planName)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendDelinquencyEmail45 provides a mock function with given fields: _a0, locale, siteURL, planName, delinquencyDate
func (_m *ServiceInterface) SendDelinquencyEmail45(_a0 string, locale string, siteURL string, planName string, delinquencyDate string) error {
ret := _m.Called(_a0, locale, siteURL, planName, delinquencyDate)
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string, string, string) error); ok {
r0 = rf(_a0, locale, siteURL, planName, delinquencyDate)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendDelinquencyEmail60 provides a mock function with given fields: _a0, locale, siteURL
func (_m *ServiceInterface) SendDelinquencyEmail60(_a0 string, locale string, siteURL string) error {
ret := _m.Called(_a0, locale, siteURL)
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string) error); ok {
r0 = rf(_a0, locale, siteURL)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendDelinquencyEmail7 provides a mock function with given fields: _a0, locale, siteURL, planName
func (_m *ServiceInterface) SendDelinquencyEmail7(_a0 string, locale string, siteURL string, planName string) error {
ret := _m.Called(_a0, locale, siteURL, planName)
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string, string) error); ok {
r0 = rf(_a0, locale, siteURL, planName)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendDelinquencyEmail75 provides a mock function with given fields: _a0, locale, siteURL, planName, delinquencyDate
func (_m *ServiceInterface) SendDelinquencyEmail75(_a0 string, locale string, siteURL string, planName string, delinquencyDate string) error {
ret := _m.Called(_a0, locale, siteURL, planName, delinquencyDate)
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string, string, string) error); ok {
r0 = rf(_a0, locale, siteURL, planName, delinquencyDate)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendDelinquencyEmail90 provides a mock function with given fields: _a0, locale, siteURL
func (_m *ServiceInterface) SendDelinquencyEmail90(_a0 string, locale string, siteURL string) error {
ret := _m.Called(_a0, locale, siteURL)
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string) error); ok {
r0 = rf(_a0, locale, siteURL)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendEmailChangeEmail provides a mock function with given fields: oldEmail, newEmail, locale, siteURL
func (_m *ServiceInterface) SendEmailChangeEmail(oldEmail string, newEmail string, locale string, siteURL string) error {
ret := _m.Called(oldEmail, newEmail, locale, siteURL)
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string, string) error); ok {
r0 = rf(oldEmail, newEmail, locale, siteURL)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendEmailChangeVerifyEmail provides a mock function with given fields: newUserEmail, locale, siteURL, token
func (_m *ServiceInterface) SendEmailChangeVerifyEmail(newUserEmail string, locale string, siteURL string, token string) error {
ret := _m.Called(newUserEmail, locale, siteURL, token)
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string, string) error); ok {
r0 = rf(newUserEmail, locale, siteURL, token)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendGuestInviteEmails provides a mock function with given fields: team, channels, senderName, senderUserId, senderProfileImage, invites, siteURL, message, errorWhenNotSent, isSystemAdmin, isFirstAdmin
func (_m *ServiceInterface) SendGuestInviteEmails(team *model.Team, channels []*model.Channel, senderName string, senderUserId string, senderProfileImage []byte, invites []string, siteURL string, message string, errorWhenNotSent bool, isSystemAdmin bool, isFirstAdmin bool) error {
ret := _m.Called(team, channels, senderName, senderUserId, senderProfileImage, invites, siteURL, message, errorWhenNotSent, isSystemAdmin, isFirstAdmin)
var r0 error
if rf, ok := ret.Get(0).(func(*model.Team, []*model.Channel, string, string, []byte, []string, string, string, bool, bool, bool) error); ok {
r0 = rf(team, channels, senderName, senderUserId, senderProfileImage, invites, siteURL, message, errorWhenNotSent, isSystemAdmin, isFirstAdmin)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendInviteEmails provides a mock function with given fields: team, senderName, senderUserId, invites, siteURL, reminderData, errorWhenNotSent, isSystemAdmin, isFirstAdmin
func (_m *ServiceInterface) SendInviteEmails(team *model.Team, senderName string, senderUserId string, invites []string, siteURL string, reminderData *model.TeamInviteReminderData, errorWhenNotSent bool, isSystemAdmin bool, isFirstAdmin bool) error {
ret := _m.Called(team, senderName, senderUserId, invites, siteURL, reminderData, errorWhenNotSent, isSystemAdmin, isFirstAdmin)
var r0 error
if rf, ok := ret.Get(0).(func(*model.Team, string, string, []string, string, *model.TeamInviteReminderData, bool, bool, bool) error); ok {
r0 = rf(team, senderName, senderUserId, invites, siteURL, reminderData, errorWhenNotSent, isSystemAdmin, isFirstAdmin)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendInviteEmailsToTeamAndChannels provides a mock function with given fields: team, channels, senderName, senderUserId, senderProfileImage, invites, siteURL, reminderData, message, errorWhenNotSent, isSystemAdmin, isFirstAdmin
func (_m *ServiceInterface) SendInviteEmailsToTeamAndChannels(team *model.Team, channels []*model.Channel, senderName string, senderUserId string, senderProfileImage []byte, invites []string, siteURL string, reminderData *model.TeamInviteReminderData, message string, errorWhenNotSent bool, isSystemAdmin bool, isFirstAdmin bool) ([]*model.EmailInviteWithError, error) {
ret := _m.Called(team, channels, senderName, senderUserId, senderProfileImage, invites, siteURL, reminderData, message, errorWhenNotSent, isSystemAdmin, isFirstAdmin)
var r0 []*model.EmailInviteWithError
if rf, ok := ret.Get(0).(func(*model.Team, []*model.Channel, string, string, []byte, []string, string, *model.TeamInviteReminderData, string, bool, bool, bool) []*model.EmailInviteWithError); ok {
r0 = rf(team, channels, senderName, senderUserId, senderProfileImage, invites, siteURL, reminderData, message, errorWhenNotSent, isSystemAdmin, isFirstAdmin)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*model.EmailInviteWithError)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(*model.Team, []*model.Channel, string, string, []byte, []string, string, *model.TeamInviteReminderData, string, bool, bool, bool) error); ok {
r1 = rf(team, channels, senderName, senderUserId, senderProfileImage, invites, siteURL, reminderData, message, errorWhenNotSent, isSystemAdmin, isFirstAdmin)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// SendLicenseInactivityEmail provides a mock function with given fields: _a0, name, locale, siteURL
func (_m *ServiceInterface) SendLicenseInactivityEmail(_a0 string, name string, locale string, siteURL string) error {
ret := _m.Called(_a0, name, locale, siteURL)
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string, string) error); ok {
r0 = rf(_a0, name, locale, siteURL)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendLicenseUpForRenewalEmail provides a mock function with given fields: _a0, name, locale, siteURL, ctaTitle, ctaLink, ctaText, daysToExpiration
func (_m *ServiceInterface) SendLicenseUpForRenewalEmail(_a0 string, name string, locale string, siteURL string, ctaTitle string, ctaLink string, ctaText string, daysToExpiration int) error {
ret := _m.Called(_a0, name, locale, siteURL, ctaTitle, ctaLink, ctaText, daysToExpiration)
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string, string, string, string, string, int) error); ok {
r0 = rf(_a0, name, locale, siteURL, ctaTitle, ctaLink, ctaText, daysToExpiration)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendMailWithEmbeddedFiles provides a mock function with given fields: to, subject, htmlBody, embeddedFiles, messageID, inReplyTo, references, category
func (_m *ServiceInterface) SendMailWithEmbeddedFiles(to string, subject string, htmlBody string, embeddedFiles map[string]io.Reader, messageID string, inReplyTo string, references string, category string) error {
ret := _m.Called(to, subject, htmlBody, embeddedFiles, messageID, inReplyTo, references, category)
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string, map[string]io.Reader, string, string, string, string) error); ok {
r0 = rf(to, subject, htmlBody, embeddedFiles, messageID, inReplyTo, references, category)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendMfaChangeEmail provides a mock function with given fields: _a0, activated, locale, siteURL
func (_m *ServiceInterface) SendMfaChangeEmail(_a0 string, activated bool, locale string, siteURL string) error {
ret := _m.Called(_a0, activated, locale, siteURL)
var r0 error
if rf, ok := ret.Get(0).(func(string, bool, string, string) error); ok {
r0 = rf(_a0, activated, locale, siteURL)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendNoCardPaymentFailedEmail provides a mock function with given fields: _a0, locale, siteURL
func (_m *ServiceInterface) SendNoCardPaymentFailedEmail(_a0 string, locale string, siteURL string) error {
ret := _m.Called(_a0, locale, siteURL)
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string) error); ok {
r0 = rf(_a0, locale, siteURL)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendNotificationMail provides a mock function with given fields: to, subject, htmlBody
func (_m *ServiceInterface) SendNotificationMail(to string, subject string, htmlBody string) error {
ret := _m.Called(to, subject, htmlBody)
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string) error); ok {
r0 = rf(to, subject, htmlBody)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendPasswordChangeEmail provides a mock function with given fields: _a0, method, locale, siteURL
func (_m *ServiceInterface) SendPasswordChangeEmail(_a0 string, method string, locale string, siteURL string) error {
ret := _m.Called(_a0, method, locale, siteURL)
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string, string) error); ok {
r0 = rf(_a0, method, locale, siteURL)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendPasswordResetEmail provides a mock function with given fields: _a0, token, locale, siteURL
func (_m *ServiceInterface) SendPasswordResetEmail(_a0 string, token *model.Token, locale string, siteURL string) (bool, error) {
ret := _m.Called(_a0, token, locale, siteURL)
var r0 bool
if rf, ok := ret.Get(0).(func(string, *model.Token, string, string) bool); ok {
r0 = rf(_a0, token, locale, siteURL)
} else {
r0 = ret.Get(0).(bool)
}
var r1 error
if rf, ok := ret.Get(1).(func(string, *model.Token, string, string) error); ok {
r1 = rf(_a0, token, locale, siteURL)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// SendPaymentFailedEmail provides a mock function with given fields: _a0, locale, failedPayment, planName, siteURL
func (_m *ServiceInterface) SendPaymentFailedEmail(_a0 string, locale string, failedPayment *model.FailedPayment, planName string, siteURL string) (bool, error) {
ret := _m.Called(_a0, locale, failedPayment, planName, siteURL)
var r0 bool
if rf, ok := ret.Get(0).(func(string, string, *model.FailedPayment, string, string) bool); ok {
r0 = rf(_a0, locale, failedPayment, planName, siteURL)
} else {
r0 = ret.Get(0).(bool)
}
var r1 error
if rf, ok := ret.Get(1).(func(string, string, *model.FailedPayment, string, string) error); ok {
r1 = rf(_a0, locale, failedPayment, planName, siteURL)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// SendRemoveExpiredLicenseEmail provides a mock function with given fields: ctaText, ctaLink, _a2, locale, siteURL
func (_m *ServiceInterface) SendRemoveExpiredLicenseEmail(ctaText string, ctaLink string, _a2 string, locale string, siteURL string) error {
ret := _m.Called(ctaText, ctaLink, _a2, locale, siteURL)
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string, string, string) error); ok {
r0 = rf(ctaText, ctaLink, _a2, locale, siteURL)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendSignInChangeEmail provides a mock function with given fields: _a0, method, locale, siteURL
func (_m *ServiceInterface) SendSignInChangeEmail(_a0 string, method string, locale string, siteURL string) error {
ret := _m.Called(_a0, method, locale, siteURL)
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string, string) error); ok {
r0 = rf(_a0, method, locale, siteURL)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendUserAccessTokenAddedEmail provides a mock function with given fields: _a0, locale, siteURL
func (_m *ServiceInterface) SendUserAccessTokenAddedEmail(_a0 string, locale string, siteURL string) error {
ret := _m.Called(_a0, locale, siteURL)
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string) error); ok {
r0 = rf(_a0, locale, siteURL)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendVerifyEmail provides a mock function with given fields: userEmail, locale, siteURL, token, redirect
func (_m *ServiceInterface) SendVerifyEmail(userEmail string, locale string, siteURL string, token string, redirect string) error {
ret := _m.Called(userEmail, locale, siteURL, token, redirect)
var r0 error
if rf, ok := ret.Get(0).(func(string, string, string, string, string) error); ok {
r0 = rf(userEmail, locale, siteURL, token, redirect)
} else {
r0 = ret.Error(0)
}
return r0
}
// SendWelcomeEmail provides a mock function with given fields: userID, _a1, verified, disableWelcomeEmail, locale, siteURL, redirect
func (_m *ServiceInterface) SendWelcomeEmail(userID string, _a1 string, verified bool, disableWelcomeEmail bool, locale string, siteURL string, redirect string) error {
ret := _m.Called(userID, _a1, verified, disableWelcomeEmail, locale, siteURL, redirect)
var r0 error
if rf, ok := ret.Get(0).(func(string, string, bool, bool, string, string, string) error); ok {
r0 = rf(userID, _a1, verified, disableWelcomeEmail, locale, siteURL, redirect)
} else {
r0 = ret.Error(0)
}
return r0
}
// Stop provides a mock function with given fields:
func (_m *ServiceInterface) Stop() {
_m.Called()
}

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

@@ -0,0 +1,136 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package email
import (
"html"
"html/template"
"net/url"
"path/filepath"
"strings"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/server/channels/utils"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/i18n"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
)
type FieldRow struct {
Cells []*model.SlackAttachmentField
}
type EmailMessageAttachment struct {
model.SlackAttachment
Pretext template.HTML
Text template.HTML
FieldRows []FieldRow
}
func (es *Service) GetMessageForNotification(post *model.Post, translateFunc i18n.TranslateFunc) string {
if strings.TrimSpace(post.Message) != "" || len(post.FileIds) == 0 {
return post.Message
}
// extract the filenames from their paths and determine what type of files are attached
infos, err := es.store.FileInfo().GetForPost(post.Id, true, false, true)
if err != nil {
mlog.Warn("Encountered error when getting files for notification message", mlog.String("post_id", post.Id), mlog.Err(err))
}
filenames := make([]string, len(infos))
onlyImages := true
for i, info := range infos {
if escaped, err := url.QueryUnescape(filepath.Base(info.Name)); err != nil {
// this should never error since filepath was escaped using url.QueryEscape
filenames[i] = escaped
} else {
filenames[i] = info.Name
}
onlyImages = onlyImages && info.IsImage()
}
props := map[string]any{"Filenames": strings.Join(filenames, ", ")}
if onlyImages {
return translateFunc("api.post.get_message_for_notification.images_sent", len(filenames), props)
}
return translateFunc("api.post.get_message_for_notification.files_sent", len(filenames), props)
}
func ProcessMessageAttachments(post *model.Post, siteURL string) []*EmailMessageAttachment {
emailMessageAttachments := []*EmailMessageAttachment{}
for _, messageAttachment := range post.Attachments() {
emailMessageAttachment := &EmailMessageAttachment{
SlackAttachment: *messageAttachment,
Pretext: prepareTextForEmail(messageAttachment.Pretext, siteURL),
Text: prepareTextForEmail(messageAttachment.Text, siteURL),
}
stripedTitle, err := utils.StripMarkdown(emailMessageAttachment.Title)
if err != nil {
mlog.Warn("Failed parse to markdown from messageatatchment title", mlog.String("post_id", post.Id), mlog.Err(err))
stripedTitle = ""
}
emailMessageAttachment.Title = stripedTitle
shortFieldRow := FieldRow{}
for i := range messageAttachment.Fields {
// Create a new instance to avoid altering the original pointer reference
// We update field value to parse markdown.
// If we do that on the original pointer, the rendered text in mattermost
// becomes invalid as its no longer a markdown string, but rather an HTML string.
field := &model.SlackAttachmentField{
Title: messageAttachment.Fields[i].Title,
Value: messageAttachment.Fields[i].Value,
Short: messageAttachment.Fields[i].Short,
}
if stringValue, ok := field.Value.(string); ok {
field.Value = prepareTextForEmail(stringValue, siteURL)
}
if !field.Short {
if len(shortFieldRow.Cells) > 0 {
emailMessageAttachment.FieldRows = append(emailMessageAttachment.FieldRows, shortFieldRow)
shortFieldRow = FieldRow{}
}
emailMessageAttachment.FieldRows = append(emailMessageAttachment.FieldRows, FieldRow{[]*model.SlackAttachmentField{field}})
} else {
shortFieldRow.Cells = append(shortFieldRow.Cells, field)
if len(shortFieldRow.Cells) == 2 {
emailMessageAttachment.FieldRows = append(emailMessageAttachment.FieldRows, shortFieldRow)
shortFieldRow = FieldRow{}
}
}
}
// collect any leftover short fields
if len(shortFieldRow.Cells) > 0 {
emailMessageAttachment.FieldRows = append(emailMessageAttachment.FieldRows, shortFieldRow)
shortFieldRow = FieldRow{}
}
emailMessageAttachments = append(emailMessageAttachments, emailMessageAttachment)
}
return emailMessageAttachments
}
func prepareTextForEmail(text, siteURL string) template.HTML {
escapedText := html.EscapeString(text)
markdownText, err := utils.MarkdownToHTML(escapedText, siteURL)
if err != nil {
mlog.Warn("Encountered error while converting markdown to HTML", mlog.Err(err))
return template.HTML(text)
}
return template.HTML(markdownText)
}

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

@@ -0,0 +1,73 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package email
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost-server/v6/model"
)
func TestProcessMessageAttachments(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
post := &model.Post{
Message: "This is the message",
}
messageAttachments := []*model.SlackAttachment{
{
Color: "#FF0000",
Pretext: "message attachment 1 pretext",
AuthorName: "author name",
AuthorLink: "https://example.com/slack_attachment_1/author_link",
AuthorIcon: "https://example.com/slack_attachment_1/author_icon",
Title: "message attachment 1 title",
TitleLink: "https://example.com/slack_attachment_1/title_link",
Text: "message attachment 1 text",
ImageURL: "https://example.com/slack_attachment_1/image",
ThumbURL: "https://example.com/slack_attachment_1/thumb",
Fields: []*model.SlackAttachmentField{
{
Short: true,
Title: "message attachment 1 field 1 title",
Value: "message attachment 1 field 1 value",
},
{
Short: false,
Title: "message attachment 1 field 2 title",
Value: "message attachment 1 field 2 value",
},
{
Short: true,
Title: "message attachment 1 field 3 title",
Value: "message attachment 1 field 3 value",
},
{
Short: true,
Title: "message attachment 1 field 4 title",
Value: "message attachment 1 field 4 value",
},
},
},
{
Color: "#FF0000",
Pretext: "message attachment 2 pretext",
AuthorName: "author name 2",
Text: "message attachment 2 text",
},
}
model.ParseSlackAttachment(post, messageAttachments)
processedAttachmentsPost := ProcessMessageAttachments(post, "https://example.com")
require.NotNil(t, processedAttachmentsPost)
require.Len(t, processedAttachmentsPost, 2)
require.Equal(t, processedAttachmentsPost[0].Color, "#FF0000")
require.Equal(t, processedAttachmentsPost[0].FieldRows[0].Cells[0].Title, "message attachment 1 field 1 title")
require.Equal(t, processedAttachmentsPost[1].Color, "#FF0000")
}

176
server/channels/app/email/service.go Обычный файл
Просмотреть файл

@@ -0,0 +1,176 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package email
import (
"io"
"net/url"
"path"
"github.com/pkg/errors"
"github.com/throttled/throttled"
"github.com/throttled/throttled/store/memstore"
"github.com/mattermost/mattermost-server/v6/model"
"github.com/mattermost/mattermost-server/v6/server/channels/app/users"
"github.com/mattermost/mattermost-server/v6/server/channels/store"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/i18n"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mlog"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/templates"
)
const (
emailRateLimitingMemstoreSize = 65536
emailRateLimitingPerHour = 20
emailRateLimitingMaxBurst = 20
TokenTypePasswordRecovery = "password_recovery"
TokenTypeVerifyEmail = "verify_email"
TokenTypeTeamInvitation = "team_invitation"
TokenTypeGuestInvitation = "guest_invitation"
TokenTypeCWSAccess = "cws_access_token"
)
func condenseSiteURL(siteURL string) string {
parsedSiteURL, _ := url.Parse(siteURL)
if parsedSiteURL.Path == "" || parsedSiteURL.Path == "/" {
return parsedSiteURL.Host
}
return path.Join(parsedSiteURL.Host, parsedSiteURL.Path)
}
type Service struct {
config func() *model.Config
license func() *model.License
userService *users.UserService
store store.Store
templatesContainer *templates.Container
perHourEmailRateLimiter *throttled.GCRARateLimiter
perDayEmailRateLimiter *throttled.GCRARateLimiter
EmailBatching *EmailBatchingJob
}
type ServiceConfig struct {
ConfigFn func() *model.Config
LicenseFn func() *model.License
TemplatesContainer *templates.Container
UserService *users.UserService
Store store.Store
}
func NewService(config ServiceConfig) (*Service, error) {
if err := config.validate(); err != nil {
return nil, err
}
service := &Service{
config: config.ConfigFn,
templatesContainer: config.TemplatesContainer,
license: config.LicenseFn,
store: config.Store,
userService: config.UserService,
}
if err := service.setUpRateLimiters(); err != nil {
return nil, err
}
service.InitEmailBatching()
return service, nil
}
func (es *Service) Stop() {
mlog.Info("Shutting down Email batching service...")
if es.EmailBatching != nil {
es.EmailBatching.Stop()
}
}
func (c *ServiceConfig) validate() error {
if c.ConfigFn == nil || c.Store == nil || c.LicenseFn == nil || c.TemplatesContainer == nil {
return errors.New("invalid service config")
}
return nil
}
func (es *Service) setUpRateLimiters() error {
store, err := memstore.New(emailRateLimitingMemstoreSize)
if err != nil {
return errors.Wrap(err, "Unable to setup email rate limiting memstore.")
}
perHourQuota := throttled.RateQuota{
MaxRate: throttled.PerHour(emailRateLimitingPerHour),
MaxBurst: emailRateLimitingMaxBurst,
}
perDayQuota := throttled.RateQuota{
MaxRate: throttled.PerDay(1),
MaxBurst: 0,
}
perHourRateLimiter, err := throttled.NewGCRARateLimiter(store, perHourQuota)
if err != nil || perHourRateLimiter == nil {
return errors.Wrap(err, "Unable to setup email rate limiting GCRA rate limiter.")
}
perDayRateLimiter, err := throttled.NewGCRARateLimiter(store, perDayQuota)
if err != nil || perDayRateLimiter == nil {
return errors.Wrap(err, "Unable to setup per day email rate limiting GCRA rate limiter.")
}
es.perHourEmailRateLimiter = perHourRateLimiter
es.perDayEmailRateLimiter = perDayRateLimiter
return nil
}
type ServiceInterface interface {
GetPerDayEmailRateLimiter() *throttled.GCRARateLimiter
NewEmailTemplateData(locale string) templates.Data
SendEmailChangeVerifyEmail(newUserEmail, locale, siteURL, token string) error
SendEmailChangeEmail(oldEmail, newEmail, locale, siteURL string) error
SendVerifyEmail(userEmail, locale, siteURL, token, redirect string) error
SendSignInChangeEmail(email, method, locale, siteURL string) error
SendWelcomeEmail(userID string, email string, verified bool, disableWelcomeEmail bool, locale, siteURL, redirect string) error
SendCloudUpgradeConfirmationEmail(userEmail, name, trialEndDate, locale, siteURL, workspaceName string, isYearly bool, embeddedFiles map[string]io.Reader) error
SendCloudWelcomeEmail(userEmail, locale, teamInviteID, workSpaceName, dns, siteURL string) error
SendPasswordChangeEmail(email, method, locale, siteURL string) error
SendUserAccessTokenAddedEmail(email, locale, siteURL string) error
SendPasswordResetEmail(email string, token *model.Token, locale, siteURL string) (bool, error)
SendMfaChangeEmail(email string, activated bool, locale, siteURL string) error
SendInviteEmails(team *model.Team, senderName string, senderUserId string, invites []string, siteURL string, reminderData *model.TeamInviteReminderData, errorWhenNotSent bool, isSystemAdmin bool, isFirstAdmin bool) error
SendGuestInviteEmails(team *model.Team, channels []*model.Channel, senderName string, senderUserId string, senderProfileImage []byte, invites []string, siteURL string, message string, errorWhenNotSent bool, isSystemAdmin bool, isFirstAdmin bool) error
SendInviteEmailsToTeamAndChannels(team *model.Team, channels []*model.Channel, senderName string, senderUserId string, senderProfileImage []byte, invites []string, siteURL string, reminderData *model.TeamInviteReminderData, message string, errorWhenNotSent bool, isSystemAdmin bool, isFirstAdmin bool) ([]*model.EmailInviteWithError, error)
SendDeactivateAccountEmail(email string, locale, siteURL string) error
SendNotificationMail(to, subject, htmlBody string) error
SendMailWithEmbeddedFiles(to, subject, htmlBody string, embeddedFiles map[string]io.Reader, messageID string, inReplyTo string, references string, category string) error
SendLicenseUpForRenewalEmail(email, name, locale, siteURL, ctaTitle, ctaLink, ctaText string, daysToExpiration int) error
SendPaymentFailedEmail(email string, locale string, failedPayment *model.FailedPayment, planName, siteURL string) (bool, error)
// Cloud delinquency email sequence
SendDelinquencyEmail7(email, locale, siteURL, planName string) error
SendDelinquencyEmail14(email, locale, siteURL, planName string) error
SendDelinquencyEmail30(email, locale, siteURL, planName string) error
SendDelinquencyEmail45(email, locale, siteURL, planName, delinquencyDate string) error
SendDelinquencyEmail60(email, locale, siteURL string) error
SendDelinquencyEmail75(email, locale, siteURL, planName, delinquencyDate string) error
SendDelinquencyEmail90(email, locale, siteURL string) error
SendNoCardPaymentFailedEmail(email string, locale string, siteURL string) error
SendRemoveExpiredLicenseEmail(ctaText, ctaLink, email, locale, siteURL string) error
AddNotificationEmailToBatch(user *model.User, post *model.Post, team *model.Team) *model.AppError
GetMessageForNotification(post *model.Post, translateFunc i18n.TranslateFunc) string
InitEmailBatching()
SendChangeUsernameEmail(newUsername, email, locale, siteURL string) error
CreateVerifyEmailToken(userID string, newEmail string) (*model.Token, error)
SendLicenseInactivityEmail(email, name, locale, siteURL string) error
Stop()
}
func (es *Service) GetPerDayEmailRateLimiter() *throttled.GCRARateLimiter {
return es.perDayEmailRateLimiter
}
func (es *Service) GetPerHourEmailRateLimiter() *throttled.GCRARateLimiter {
return es.perHourEmailRateLimiter
}

48
server/channels/app/email/utils.go Обычный файл
Просмотреть файл

@@ -0,0 +1,48 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package email
import (
"github.com/mattermost/mattermost-server/v6/server/channels/utils"
"github.com/mattermost/mattermost-server/v6/server/platform/shared/mail"
)
func (es *Service) mailServiceConfig(replyToAddress string) *mail.SMTPConfig {
emailSettings := es.config().EmailSettings
hostname := utils.GetHostnameFromSiteURL(*es.config().ServiceSettings.SiteURL)
if replyToAddress == "" {
replyToAddress = *emailSettings.ReplyToAddress
}
cfg := mail.SMTPConfig{
Hostname: hostname,
ConnectionSecurity: *emailSettings.ConnectionSecurity,
SkipServerCertificateVerification: *emailSettings.SkipServerCertificateVerification,
ServerName: *emailSettings.SMTPServer,
Server: *emailSettings.SMTPServer,
Port: *emailSettings.SMTPPort,
ServerTimeout: *emailSettings.SMTPServerTimeout,
Username: *emailSettings.SMTPUsername,
Password: *emailSettings.SMTPPassword,
EnableSMTPAuth: *emailSettings.EnableSMTPAuth,
SendEmailNotifications: *emailSettings.SendEmailNotifications,
FeedbackName: *emailSettings.FeedbackName,
FeedbackEmail: *emailSettings.FeedbackEmail,
ReplyToAddress: replyToAddress,
}
return &cfg
}
func (es *Service) GetTrackFlowStartedByRole(isFirstAdmin bool, isSystemAdmin bool) string {
trackFlowStartedByRole := "su"
if isFirstAdmin {
trackFlowStartedByRole = "fa"
} else if isSystemAdmin {
trackFlowStartedByRole = "sa"
}
return trackFlowStartedByRole
}